commit f7fb7f963ca29b09ac87017264e73b5c501348af Author: Lucas Santana Date: Tue Dec 17 18:50:58 2024 -0300 initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..ced8fec Binary files /dev/null and b/.DS_Store differ diff --git a/background.js b/background.js new file mode 100644 index 0000000..5676142 --- /dev/null +++ b/background.js @@ -0,0 +1,181 @@ +let currentPostData = {}; +let authToken = null; +let negocios_id = null; +let negocios_nome = null; +let user_id = null; +let expires = null; + +// URL base da API do Bubble no Launchr +const API_URL = 'https://launchr.com.br/version-test/api/1.1/wf'; + +// Recuperar o token e informações armazenadas ao iniciar a extensão +chrome.storage.local.get(['authToken', 'negocios_id', 'negocios_nome', 'user_id', 'expires'], (result) => { + if (result.authToken) { + authToken = result.authToken; + negocios_id = result.negocios_id; + negocios_nome = result.negocios_nome; + user_id = result.user_id; + expires = result.expires; + } +}); + +// Função para lidar com erros de autenticação +function handleAuthError() { + // Remover o token e informações armazenadas + authToken = null; + chrome.storage.local.remove(['authToken', 'negocios_id', 'negocios_nome', 'user_id', 'expires'], () => { + console.log('Token de autenticação removido.'); + // Opcional: Notificar o usuário sobre a necessidade de login + }); +} + +// Listener para mensagens +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'storePostData') { + currentPostData = request.data || {}; + sendResponse({ success: true }); + } else if (request.action === 'getPostData') { + sendResponse({ data: currentPostData }); + } else if (request.action === 'savePost') { + const postDataToSave = request.data; + let responseSent = false; + + if (!postDataToSave) { + sendResponse({ success: false, error: 'Dados do post não fornecidos.' }); + return; + } + + if (!authToken) { + handleAuthError(); + sendResponse({ success: false, error: 'Usuário não autenticado. Faça login novamente.' }); + return true; + } + + const dataToSend = { + content: postDataToSave.content, + author: postDataToSave.author, + authorProfileLink: postDataToSave.authorProfileLink, + date: postDataToSave.date, + postLink: postDataToSave.postLink, + category: postDataToSave.category, + user_id: user_id, + negocios_id: negocios_id + }; + + fetch(`${API_URL}/novo_swipe_file`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': authToken + }, + body: JSON.stringify(dataToSend) + }) + .then(response => { + if (response.ok) { + return response.json(); + } else if (response.status === 401) { + handleAuthError(); + if (!responseSent) { + sendResponse({ success: false, error: 'Token expirado ou inválido. Faça login novamente.' }); + responseSent = true; + } + throw new Error('Token inválido ou expirado.'); + } else { + return response.json().then(data => { + if (!responseSent) { + sendResponse({ success: false, error: data.message || 'Erro ao salvar o post.' }); + responseSent = true; + } + throw new Error(data.message || 'Erro na requisição.'); + }); + } + }) + .then(data => { + console.log('Post salvo com sucesso:', data); + if (!responseSent) { + sendResponse({ success: true }); + responseSent = true; + } + }) + .catch(error => { + console.error('Erro ao salvar o post:', error); + if (!responseSent) { + sendResponse({ success: false, error: 'Erro ao salvar o post.' }); + responseSent = true; + } + }); + + return true; + } else if (request.action === 'login') { + console.log('Iniciando o processo de login...'); + const { email, password } = request.data || {}; + let responseSent = false; + + if (!email || !password) { + sendResponse({ success: false, error: 'E-mail ou senha não fornecidos.' }); + console.log('Email ou senha não fornecidos'); + return; + } + + fetch(`${API_URL}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: email, + password: password + }) + }) + .then(response => { + if (response.ok) { + return response.json(); + } else { + if (!responseSent) { + sendResponse({ success: false, error: 'Erro na autenticação. Verifique suas credenciais.' }); + responseSent = true; + } + throw new Error('Erro na autenticação.'); + } + }) + .then(data => { + if (data.status === 'success' && data.response && data.response.token) { + authToken = 'Bearer ' + data.response.token; + negocios_id = data.response.negocios_id; + negocios_nome = data.response.negocios_nome; + user_id = data.response.user_id; + expires = data.response.expires; + + chrome.storage.local.set({ authToken, negocios_id, negocios_nome, user_id, expires }, () => { + console.log('Usuário autenticado com sucesso.'); + if (!responseSent) { + sendResponse({ success: true }); + responseSent = true; + } + }); + } else { + console.error('Erro na autenticação:', data); + if (!responseSent) { + sendResponse({ success: false, error: 'Falha na autenticação. Verifique suas credenciais.' }); + responseSent = true; + } + } + }) + .catch(error => { + console.error('Erro na autenticação:', error); + if (!responseSent) { + sendResponse({ success: false, error: 'Erro ao realizar o login. Por favor, tente novamente.' }); + responseSent = true; + } + }); + + return true; + } else if (request.action === 'logout') { + authToken = null; + chrome.storage.local.remove(['authToken', 'negocios_id', 'negocios_nome', 'user_id', 'expires'], () => { + console.log('Usuário deslogado com sucesso.'); + sendResponse({ success: true }); + }); + return true; + } +}); \ No newline at end of file diff --git a/content.css b/content.css new file mode 100644 index 0000000..c8cf368 --- /dev/null +++ b/content.css @@ -0,0 +1,60 @@ +/* Estilos do botão */ +.launchr-save-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background-color: #4F46E5; + color: white; + border: none; + margin-left: 8px; + min-width: 150px; +} + +.launchr-save-button:hover:not(:disabled) { + background-color: #4338CA; +} + +.launchr-save-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.launchr-save-button.loading { + background-color: #6B7280; +} + +.launchr-save-button.success { + background-color: #10B981; +} + +.launchr-save-button.error { + background-color: #EF4444; +} + +.launchr-save-button.auth { + background-color: #F59E0B; +} + +/* Animação de loading */ +.launchr-save-button.loading::after { + content: ''; + width: 16px; + height: 16px; + margin-left: 8px; + border: 2px solid #fff; + border-radius: 50%; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/content.js b/content.js new file mode 100644 index 0000000..95dbae5 --- /dev/null +++ b/content.js @@ -0,0 +1,74 @@ +// Função para extrair dados do post atual +function extractPostData() { + // Selecionar o post atual + const post = document.querySelector('.occludable-update'); + + if (!post) { + console.error('Post não encontrado.'); + return null; + } + + // Extrair informações do post + const contentElement = post.querySelector('.feed-shared-update-v2__description, .break-words, [data-test-id="feed-update-message"]'); + const authorElement = post.querySelector('.feed-shared-actor__name, .update-components-actor__name'); + const dateElement = post.querySelector('span.feed-shared-actor__sub-description span[aria-hidden="true"], span.update-components-actor__sub-description time'); + + const postContent = contentElement ? contentElement.innerText.trim() : ''; + const postAuthor = authorElement ? authorElement.innerText.trim() : ''; + + // Obter o link do perfil do autor + const authorProfileLinkElement = authorElement ? authorElement.closest('a') : null; + const baseUrl = 'https://www.linkedin.com'; + const authorProfileLink = authorProfileLinkElement ? new URL(authorProfileLinkElement.getAttribute('href'), baseUrl).href : ''; + + // Obter a data do post + const postDate = dateElement ? dateElement.innerText.trim() : ''; + + // Obter o link do post + const postLinkElement = post.querySelector('a[href*="/feed/update/"], a[data-control-name="share_link"], a[href*="/posts/"]'); + const postLink = postLinkElement ? postLinkElement.href : window.location.href; + + const postData = { + content: postContent, + author: postAuthor, + authorProfileLink: authorProfileLink, + date: postDate, + postLink: postLink + }; + + return postData; +} + +// Adicionar um botão na página para salvar o post +function addSavePostButton() { + const button = document.createElement('button'); + button.innerText = 'Salvar Post'; + button.id = 'savePostButton'; + button.style.position = 'fixed'; + button.style.top = '10px'; + button.style.right = '10px'; + button.style.zIndex = '9999'; + + button.addEventListener('click', () => { + const postData = extractPostData(); + + if (postData) { + // Enviar os dados do post para o background script + chrome.runtime.sendMessage({ action: 'storePostData', data: postData }, (response) => { + if (response && response.success) { + console.log('Dados do post enviados para o background script.'); + alert('Dados do post prontos para serem salvos. Clique no ícone da extensão para prosseguir.'); + } else { + console.error('Erro ao enviar dados do post:', response.error); + } + }); + } else { + alert('Não foi possível extrair os dados do post.'); + } + }); + + document.body.appendChild(button); +} + +// Chamar a função para adicionar o botão +addSavePostButton(); \ No newline at end of file diff --git a/icon128.png b/icon128.png new file mode 100644 index 0000000..a8e7964 Binary files /dev/null and b/icon128.png differ diff --git a/icon16.png b/icon16.png new file mode 100644 index 0000000..46294ab Binary files /dev/null and b/icon16.png differ diff --git a/icon32.png b/icon32.png new file mode 100644 index 0000000..4cf3b7b Binary files /dev/null and b/icon32.png differ diff --git a/icon48.png b/icon48.png new file mode 100644 index 0000000..8192712 Binary files /dev/null and b/icon48.png differ diff --git a/images/icon128.png b/images/icon128.png new file mode 100644 index 0000000..a8e7964 Binary files /dev/null and b/images/icon128.png differ diff --git a/images/icon16.png b/images/icon16.png new file mode 100644 index 0000000..46294ab Binary files /dev/null and b/images/icon16.png differ diff --git a/images/icon32.png b/images/icon32.png new file mode 100644 index 0000000..4cf3b7b Binary files /dev/null and b/images/icon32.png differ diff --git a/images/icon48.png b/images/icon48.png new file mode 100644 index 0000000..8192712 Binary files /dev/null and b/images/icon48.png differ diff --git a/images/logo.png b/images/logo.png new file mode 100644 index 0000000..d982c64 Binary files /dev/null and b/images/logo.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..97b3de0 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Launchr + + +
+ + + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..e69de29 diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..d982c64 Binary files /dev/null and b/logo.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d0686d6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,38 @@ +{ + "manifest_version": 2, + "name": "Sua Extensão", + "version": "1.0", + "description": "Extensão para salvar posts do LinkedIn e gerar mensagens personalizadas.", + "permissions": [ + "activeTab", + "storage", + "contextMenus", + "tabs", + "https://launchr.com.br/*", + "https://api.openai.com/*" + ], + "background": { + "scripts": ["background.js"], + "persistent": false + }, + "browser_action": { + "default_popup": "popup.html", + "default_title": "Minha Extensão", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + }, + "content_scripts": [ + { + "matches": ["*://www.linkedin.com/*"], + "js": ["content.js"] + } + ], + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } +} \ No newline at end of file diff --git a/menu.html b/menu.html new file mode 100644 index 0000000..09d6bad --- /dev/null +++ b/menu.html @@ -0,0 +1,24 @@ + + + + + Login + + + +
+

Login

+
+ + + + + + + +
+
+ + + + \ No newline at end of file diff --git a/menu.js b/menu.js new file mode 100644 index 0000000..b39d4e7 --- /dev/null +++ b/menu.js @@ -0,0 +1,23 @@ +document.addEventListener('DOMContentLoaded', () => { + const loginForm = document.getElementById('loginForm'); + + loginForm.addEventListener('submit', function(event) { + event.preventDefault(); + + const email = document.getElementById('emailInput').value.trim(); + const password = document.getElementById('passwordInput').value.trim(); + + if (email && password) { + chrome.runtime.sendMessage({ action: 'login', data: { email: email, password: password } }, function(response) { + if (response && response.success) { + alert('Login realizado com sucesso!'); + window.close(); + } else { + alert('Falha no login: ' + (response.error || 'Erro desconhecido.')); + } + }); + } else { + alert('Por favor, preencha os campos de e-mail e senha.'); + } + }); + }); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a2488c --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "watch": "vite build --watch" + } +} \ No newline at end of file diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..0a16961 --- /dev/null +++ b/popup.html @@ -0,0 +1,53 @@ + + + + + Salvar Post + + + +
+

Salvar Post

+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + \ No newline at end of file diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..29e7075 --- /dev/null +++ b/popup.js @@ -0,0 +1,130 @@ +document.addEventListener('DOMContentLoaded', () => { + // Recuperar os dados do post do background script + chrome.runtime.sendMessage({ action: 'getPostData' }, (response) => { + if (response && response.data) { + const postData = response.data; + + // Preencher os campos com os dados do post + document.getElementById('author').value = postData.author || ''; + document.getElementById('authorProfileLink').value = postData.authorProfileLink || ''; + document.getElementById('postLink').value = postData.postLink || ''; + document.getElementById('date').value = postData.date || ''; + document.getElementById('content').value = postData.content || ''; + } else { + console.log('Nenhum dado do post disponível.'); + } + }); + + // Referência ao popup e aos botões + const successPopup = document.getElementById('successPopup'); + const cancelButton = document.getElementById('cancelButton'); + const viewButton = document.getElementById('viewButton'); + + let negocios_nome = ''; + let negocios_id = ''; + + // Recuperar negocios_nome e negocios_id do storage + chrome.storage.local.get(['negocios_nome', 'negocios_id'], (result) => { + if (result.negocios_nome && result.negocios_id) { + negocios_nome = result.negocios_nome; + negocios_id = result.negocios_id; + } else { + console.error('Não foi possível obter as informações do negócio.'); + } + }); + + // Função para exibir o popup de sucesso + function showSuccessPopup() { + successPopup.style.display = 'flex'; + } + + // Evento para o botão de cancelar e fechar o popup + cancelButton.addEventListener('click', () => { + successPopup.style.display = 'none'; + // Fechar o popup principal + window.close(); + }); + + // Evento para o botão de visualizar o Swipe File + viewButton.addEventListener('click', () => { + if (negocios_nome && negocios_id) { + // Construir o link para visualizar o Swipe File + const baseUrl = 'https://launchr.com.br'; + const encodedNegociosNome = encodeURIComponent(negocios_nome); + const swipeFileUrl = `${baseUrl}/posts/${encodedNegociosNome}-${negocios_id}?tab=Swipe%20File`; + + // Abrir o link em uma nova aba + chrome.tabs.create({ url: swipeFileUrl }, () => { + // Fechar o popup após abrir a nova aba + window.close(); + }); + } else { + alert('Informações do negócio não disponíveis.'); + } + }); + + // Lidar com o clique no botão de salvar + const saveButton = document.getElementById('saveButton'); + if (saveButton) { + saveButton.addEventListener('click', () => { + try { + // Obter dados atualizados do formulário + const updatedData = { + author: document.getElementById('author')?.value || '', + authorProfileLink: document.getElementById('authorProfileLink')?.value || '', + postLink: document.getElementById('postLink')?.value || '', + date: document.getElementById('date')?.value || '', + content: document.getElementById('content')?.value || '', + category: document.getElementById('category')?.value || '' + }; + + if (!updatedData.category) { + alert('Por favor, selecione uma categoria.'); + return; + } + + // Desabilitar o botão para evitar múltiplos cliques + saveButton.disabled = true; + saveButton.innerText = 'Salvando...'; + + // Enviar dados para o background script para salvar + chrome.runtime.sendMessage({ action: 'savePost', data: updatedData }, (response) => { + if (response && response.success) { + // Exibir o popup de sucesso + showSuccessPopup(); + } else { + // Exibir mensagem de erro + alert(response.error || 'Houve um erro ao enviar o post. Por favor, tente novamente.'); + // Reativar o botão de salvar + saveButton.disabled = false; + saveButton.innerText = 'Salvar'; + } + }); + } catch (error) { + console.error('Erro ao preparar os dados para salvar:', error); + alert('Ocorreu um erro ao preparar os dados. Por favor, tente novamente.'); + // Reativar o botão de salvar + saveButton.disabled = false; + saveButton.innerText = 'Salvar'; + } + }); + } else { + console.error('Elemento saveButton não encontrado.'); + } + + // Lidar com o clique no botão de logout (se aplicável) + const logoutButton = document.getElementById('logoutButton'); + if (logoutButton) { + logoutButton.addEventListener('click', () => { + if (confirm('Você realmente deseja fazer logout?')) { + chrome.runtime.sendMessage({ action: 'logout' }, (response) => { + if (response && response.success) { + window.close(); + } else { + alert('Erro ao realizar logout.'); + } + }); + } + }); + } + }); \ No newline at end of file diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js new file mode 100644 index 0000000..31ef5f1 --- /dev/null +++ b/scripts/copy-assets.js @@ -0,0 +1,34 @@ +import { copyFileSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const distDir = resolve(__dirname, '../dist'); +const assetsDir = resolve(distDir, 'src/assets'); + +// Criar diretórios necessários +mkdirSync(distDir, { recursive: true }); +mkdirSync(assetsDir, { recursive: true }); + +// Lista de arquivos para copiar +const files = [ + { src: '../manifest.json', dest: '../dist/manifest.json' }, + { src: '../src/assets/icon16.png', dest: '../dist/src/assets/icon16.png' }, + { src: '../src/assets/icon32.png', dest: '../dist/src/assets/icon32.png' }, + { src: '../src/assets/icon48.png', dest: '../dist/src/assets/icon48.png' }, + { src: '../src/assets/icon128.png', dest: '../dist/src/assets/icon128.png' }, + { src: '../src/content/index.css', dest: '../dist/content.css' } +]; + +// Copiar arquivos +files.forEach(file => { + try { + copyFileSync( + resolve(__dirname, file.src), + resolve(__dirname, file.dest) + ); + console.log(`Copiado: ${file.src} -> ${file.dest}`); + } catch (err) { + console.error(`Erro ao copiar ${file.src}:`, err); + } +}); \ No newline at end of file diff --git a/src/assets/createDefaultIcon.ts b/src/assets/createDefaultIcon.ts new file mode 100644 index 0000000..658f27a --- /dev/null +++ b/src/assets/createDefaultIcon.ts @@ -0,0 +1,19 @@ +import sharp from 'sharp'; +import { resolve } from 'path'; + +// Criar um ícone SVG simples +const svgIcon = ` + + + + L + + +`; + +// Salvar como PNG +sharp(Buffer.from(svgIcon)) + .png() + .toFile(resolve(__dirname, 'icon.png')) + .then(() => console.log('Ícone padrão criado')) + .catch(err => console.error('Erro ao criar ícone padrão:', err)); \ No newline at end of file diff --git a/src/assets/generateIcons.ts b/src/assets/generateIcons.ts new file mode 100644 index 0000000..129ab8b --- /dev/null +++ b/src/assets/generateIcons.ts @@ -0,0 +1,19 @@ +import sharp from 'sharp'; +import { mkdirSync } from 'fs'; +import { resolve } from 'path'; + +const sizes = [16, 32, 48, 128]; +const inputIcon = resolve(__dirname, 'icon.png'); +const outputDir = resolve(__dirname, '../../dist/src/assets'); + +// Criar diretório de saída +mkdirSync(outputDir, { recursive: true }); + +// Gerar ícones em diferentes tamanhos +sizes.forEach(size => { + sharp(inputIcon) + .resize(size, size) + .toFile(resolve(outputDir, `icon${size}.png`)) + .then(() => console.log(`Ícone ${size}x${size} gerado com sucesso`)) + .catch(err => console.error(`Erro ao gerar ícone ${size}x${size}:`, err)); +}); \ No newline at end of file diff --git a/src/assets/icon128.png b/src/assets/icon128.png new file mode 100644 index 0000000..a8e7964 Binary files /dev/null and b/src/assets/icon128.png differ diff --git a/src/assets/icon16.png b/src/assets/icon16.png new file mode 100644 index 0000000..46294ab Binary files /dev/null and b/src/assets/icon16.png differ diff --git a/src/assets/icon32.png b/src/assets/icon32.png new file mode 100644 index 0000000..4cf3b7b Binary files /dev/null and b/src/assets/icon32.png differ diff --git a/src/assets/icon48.png b/src/assets/icon48.png new file mode 100644 index 0000000..8192712 Binary files /dev/null and b/src/assets/icon48.png differ diff --git a/src/assets/logo.png b/src/assets/logo.png new file mode 100644 index 0000000..d982c64 Binary files /dev/null and b/src/assets/logo.png differ diff --git a/src/background/index.ts b/src/background/index.ts new file mode 100644 index 0000000..e186e46 --- /dev/null +++ b/src/background/index.ts @@ -0,0 +1,123 @@ +let currentPostData = {}; +let authToken: string | null = null; +let negocios_id: string | null = null; +let negocios_nome: string | null = null; +let user_id: string | null = null; + +// URL base da API do Bubble no Launchr +const API_URL = 'https://launchr.com.br/api/1.1/wf'; + +// Variável para controlar a janela de login +let loginWindowId: number | null = null; + +// Função para verificar autenticação +const checkAuth = (): Promise => { + return new Promise((resolve) => { + chrome.storage.local.get(['authToken'], (result) => { + authToken = result.authToken; + resolve(!!authToken); + }); + }); +}; + +// Função para abrir popup de login +const openLoginPopup = () => { + if (loginWindowId === null) { + chrome.windows.create({ + url: chrome.runtime.getURL('index.html#login'), + type: 'popup', + width: 400, + height: 600, + left: Math.round((screen.width - 400) / 2), + top: Math.round((screen.height - 600) / 2), + focused: true + }, (window) => { + if (window) { + loginWindowId = window.id; + } + }); + } else { + chrome.windows.update(loginWindowId, { focused: true }); + } +}; + +// Listener para quando a janela de login é fechada +chrome.windows.onRemoved.addListener((windowId) => { + if (windowId === loginWindowId) { + loginWindowId = null; + } +}); + +// Listener para mensagens do content script e popup +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.type === 'SAVE_POST' || request.type === 'SAVE_IDEA') { + checkAuth().then(isAuthenticated => { + if (!isAuthenticated) { + openLoginPopup(); + sendResponse({ success: false, error: 'AUTH_REQUIRED' }); + } else { + // Implementar lógica de salvamento + handleSaveRequest(request, sendResponse); + } + }); + return true; // Indica que a resposta será assíncrona + } + + if (request.type === 'CHECK_AUTH') { + checkAuth().then(isAuthenticated => { + sendResponse({ isAuthenticated }); + }); + return true; + } +}); + +// Listener para o clique no ícone da extensão +chrome.action.onClicked.addListener(async () => { + const isAuthenticated = await checkAuth(); + if (!isAuthenticated) { + openLoginPopup(); + } else { + // Abrir popup principal da extensão + chrome.windows.create({ + url: chrome.runtime.getURL('index.html'), + type: 'popup', + width: 300, + height: 500, + left: Math.round((screen.width - 300) / 2), + top: Math.round((screen.height - 500) / 2) + }); + } +}); + +// Função para lidar com as requisições de salvamento +const handleSaveRequest = async (request: any, sendResponse: (response: any) => void) => { + try { + const endpoint = request.type === 'SAVE_IDEA' ? 'nova_ideia' : 'novo_swipe_file'; + const response = await fetch(`${API_URL}/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': authToken || '' + }, + body: JSON.stringify(request.data) + }); + + if (!response.ok) { + if (response.status === 401) { + // Token inválido ou expirado + chrome.storage.local.remove(['authToken']); + authToken = null; + openLoginPopup(); + sendResponse({ success: false, error: 'AUTH_REQUIRED' }); + return; + } + throw new Error('Erro na requisição'); + } + + const data = await response.json(); + sendResponse({ success: true, data }); + } catch (error) { + console.error('Erro:', error); + sendResponse({ success: false, error: 'Erro ao processar requisição' }); + } +}; \ No newline at end of file diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 0000000..2bcd929 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { setAuth } from '../store/slices/authSlice'; + +const LoginForm = () => { + const dispatch = useDispatch(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const response = await fetch('https://launchr.com.br/api/1.1/wf/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + const data = await response.json(); + + if (data.authToken) { + // Salvar no chrome.storage + chrome.storage.local.set({ + authToken: data.authToken, + negocios_id: data.negocios_id, + negocios_nome: data.negocios_nome, + user_id: data.user_id, + }); + + // Atualizar estado + dispatch(setAuth({ + authToken: data.authToken, + negocios_id: data.negocios_id, + negocios_nome: data.negocios_nome, + user_id: data.user_id, + isAuthenticated: true, + })); + } + } catch (error) { + console.error('Erro no login:', error); + } finally { + setLoading(false); + } + }; + + return ( +
+

Faça o seu login

+

Preencha suas informações e faça o login no Launchr

+ +
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + required + /> +
+ + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx new file mode 100644 index 0000000..6efa5ce --- /dev/null +++ b/src/components/Menu.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '../store'; +import { setAuth } from '../store/slices/authSlice'; +import LoginForm from './LoginForm'; +import Dashboard from './Dashboard'; + +const Menu = () => { + const dispatch = useDispatch(); + const isAuthenticated = useSelector((state: RootState) => state.auth.isAuthenticated); + + useEffect(() => { + // Verificar autenticação ao carregar + chrome.storage.local.get( + ['authToken', 'negocios_id', 'negocios_nome', 'user_id'], + (result) => { + if (result.authToken) { + dispatch(setAuth({ + authToken: result.authToken, + negocios_id: result.negocios_id, + negocios_nome: result.negocios_nome, + user_id: result.user_id, + isAuthenticated: true, + })); + } + } + ); + }, [dispatch]); + + return ( +
+ + +
+ {isAuthenticated ? : } +
+
+ ); +}; + +export default Menu; \ No newline at end of file diff --git a/src/content/components/SaveButton.css b/src/content/components/SaveButton.css new file mode 100644 index 0000000..d5d6c44 --- /dev/null +++ b/src/content/components/SaveButton.css @@ -0,0 +1,59 @@ +.launchr-save-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background-color: #4F46E5; + color: white; + border: none; + margin-left: 8px; + min-width: 150px; +} + +.launchr-save-button:hover:not(:disabled) { + background-color: #4338CA; +} + +.launchr-save-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.launchr-save-button.loading { + background-color: #6B7280; +} + +.launchr-save-button.success { + background-color: #10B981; +} + +.launchr-save-button.error { + background-color: #EF4444; +} + +.launchr-save-button.auth { + background-color: #F59E0B; +} + +/* Animação de loading */ +.launchr-save-button.loading::after { + content: ''; + width: 16px; + height: 16px; + margin-left: 8px; + border: 2px solid #fff; + border-radius: 50%; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} \ No newline at end of file diff --git a/src/content/components/SaveButton.tsx b/src/content/components/SaveButton.tsx new file mode 100644 index 0000000..dcf338a --- /dev/null +++ b/src/content/components/SaveButton.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; + +interface SaveButtonProps { + onSave: () => Promise<{ success: boolean; error?: string }>; +} + +const SaveButton = ({ onSave }: SaveButtonProps) => { + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error' | 'auth'>('idle'); + + const handleClick = async () => { + setStatus('loading'); + try { + const result = await onSave(); + + if (result.error === 'AUTH_REQUIRED') { + setStatus('auth'); + } else if (result.success) { + setStatus('success'); + } else { + setStatus('error'); + } + + setTimeout(() => { + setStatus('idle'); + }, 2000); + } catch (error) { + setStatus('error'); + setTimeout(() => { + setStatus('idle'); + }, 2000); + } + }; + + const getButtonText = () => { + switch (status) { + case 'loading': + return 'Salvando...'; + case 'success': + return '✓ Salvo!'; + case 'error': + return '✕ Erro ao salvar'; + case 'auth': + return 'Faça login para salvar'; + default: + return 'Salvar no Launchr'; + } + }; + + return ( + + ); +}; + +export default SaveButton; \ No newline at end of file diff --git a/src/content/index.css b/src/content/index.css new file mode 100644 index 0000000..88332eb --- /dev/null +++ b/src/content/index.css @@ -0,0 +1,67 @@ +/* Estilos do botão */ +.launchr-save-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background-color: #4F46E5; + color: white; + border: none; + margin-left: 8px; + min-width: 150px; +} + +.launchr-save-button:hover:not(:disabled) { + background-color: #4338CA; +} + +.launchr-save-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.launchr-save-button.loading { + background-color: #6B7280; +} + +.launchr-save-button.success { + background-color: #10B981; +} + +.launchr-save-button.error { + background-color: #EF4444; +} + +.launchr-save-button.auth { + background-color: #F59E0B; +} + +/* Animação de loading */ +.launchr-save-button.loading::after { + content: ''; + width: 16px; + height: 16px; + margin-left: 8px; + border: 2px solid #fff; + border-radius: 50%; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Container do botão */ +.launchr-save-button-container { + display: inline-flex; + align-items: center; + margin-left: 8px; +} \ No newline at end of file diff --git a/src/content/index.js b/src/content/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/content/index.ts b/src/content/index.ts new file mode 100644 index 0000000..57cdb1e --- /dev/null +++ b/src/content/index.ts @@ -0,0 +1,72 @@ +import { createRoot } from 'react-dom/client'; +import SaveButton from './components/SaveButton'; +import './index.css'; + +function addSaveButtonToPost(post: Element) { + if (post.querySelector('.launchr-save-button-container')) { + return; + } + + // Criar container para o botão + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'launchr-save-button-container'; + + // Criar root para o React + const root = createRoot(buttonContainer); + + // Renderizar o componente SaveButton + root.render( + { + const contentElement = post.querySelector('.feed-shared-update-v2__description, .break-words'); + const authorElement = post.querySelector('.feed-shared-actor__name, .update-components-actor__name'); + const dateElement = post.querySelector('span.feed-shared-actor__sub-description span[aria-hidden="true"], span.update-components-actor__sub-description time'); + const postLinkElement = post.querySelector('a[href*="/feed/update/"], a[data-control-name="share_link"], a[href*="/posts/"]'); + + const content = contentElement?.textContent?.trim() || ''; + const author = authorElement?.textContent?.trim() || ''; + const date = dateElement?.textContent?.trim() || ''; + const postLink = postLinkElement?.getAttribute('href') || window.location.href; + + return new Promise((resolve) => { + chrome.runtime.sendMessage({ + type: 'SAVE_POST', + data: { + content, + author, + date, + postLink + } + }, (response) => { + resolve(response); + }); + }); + }} + /> + ); + + // Encontrar onde inserir o botão + const actionsContainer = post.querySelector('.feed-shared-social-actions, .social-details-social-counts'); + if (actionsContainer) { + actionsContainer.appendChild(buttonContainer); + } else { + post.appendChild(buttonContainer); + } +} + +// Observer para detectar novos posts +const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof Element && node.matches('[data-id]')) { + addSaveButtonToPost(node); + } + }); + }); +}); + +// Iniciar observação +observer.observe(document.body, { + childList: true, + subtree: true +}); \ No newline at end of file diff --git a/src/content/style.css b/src/content/style.css new file mode 100644 index 0000000..5a7ec86 --- /dev/null +++ b/src/content/style.css @@ -0,0 +1,67 @@ +/* Estilos do botão de salvar */ +.launchr-save-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background-color: #4F46E5; + color: white; + border: none; + margin-left: 8px; + min-width: 150px; +} + +.launchr-save-button:hover:not(:disabled) { + background-color: #4338CA; +} + +.launchr-save-button:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.launchr-save-button.loading { + background-color: #6B7280; +} + +.launchr-save-button.success { + background-color: #10B981; +} + +.launchr-save-button.error { + background-color: #EF4444; +} + +.launchr-save-button.auth { + background-color: #F59E0B; +} + +/* Animação de loading */ +.launchr-save-button.loading::after { + content: ''; + width: 16px; + height: 16px; + margin-left: 8px; + border: 2px solid #fff; + border-radius: 50%; + border-top-color: transparent; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Container do botão */ +.launchr-save-button-container { + display: inline-flex; + align-items: center; + margin-left: 8px; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..de4d11a --- /dev/null +++ b/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/src/popup/Router.tsx b/src/popup/Router.tsx new file mode 100644 index 0000000..25e507b --- /dev/null +++ b/src/popup/Router.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import LoginForm from '../components/LoginForm'; +import Dashboard from '../components/Dashboard'; +import { setAuth } from '../store/slices/authSlice'; + +const Router = () => { + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const dispatch = useDispatch(); + + useEffect(() => { + chrome.runtime.sendMessage({ type: 'CHECK_AUTH' }, (response) => { + if (response.isAuthenticated) { + chrome.storage.local.get( + ['authToken', 'negocios_id', 'negocios_nome', 'user_id'], + (result) => { + dispatch(setAuth({ + authToken: result.authToken, + negocios_id: result.negocios_id, + negocios_nome: result.negocios_nome, + user_id: result.user_id, + isAuthenticated: true, + })); + setIsAuthenticated(true); + } + ); + } + setIsLoading(false); + }); + }, [dispatch]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + return isAuthenticated ? : ; +}; + +export default Router; \ No newline at end of file diff --git a/src/popup/index.tsx b/src/popup/index.tsx new file mode 100644 index 0000000..f401fad --- /dev/null +++ b/src/popup/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { store } from '../store'; +import Router from './Router'; +import '../index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +); \ No newline at end of file diff --git a/src/popup/styles.css b/src/popup/styles.css new file mode 100644 index 0000000..aa79844 --- /dev/null +++ b/src/popup/styles.css @@ -0,0 +1,128 @@ +/* Reset e estilos base */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #1f1f1f; + color: white; + width: 300px; + min-height: 400px; + max-height: 500px; + overflow-y: auto; +} + +/* Container principal */ +.popup-container { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Header */ +.popup-header { + display: flex; + align-items: center; + padding: 12px 16px; + background-color: #1f1f1f; + border-bottom: 1px solid #333; +} + +.popup-header img { + height: 24px; + width: auto; + margin-right: 12px; +} + +.popup-header h1 { + font-size: 16px; + font-weight: 500; + color: white; +} + +/* Conteúdo principal */ +.popup-content { + flex: 1; + padding: 16px; +} + +/* Formulários */ +.form-group { + margin-bottom: 12px; +} + +.form-label { + display: block; + font-size: 12px; + font-weight: 400; + color: #9ca3af; + margin-bottom: 4px; +} + +.form-input { + width: 100%; + padding: 8px; + border: 1px solid #333; + border-radius: 4px; + background-color: #2d2d2d; + color: white; + font-size: 13px; +} + +.form-input:focus { + outline: none; + border-color: #4f46e5; +} + +textarea.form-input { + min-height: 100px; + resize: vertical; +} + +/* Botão */ +.button { + width: 100%; + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + margin-top: 8px; +} + +.button-primary { + background-color: #4f46e5; + color: white; +} + +.button-primary:hover { + background-color: #4338ca; +} + +.button-primary:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* Scrollbar personalizada */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #1f1f1f; +} + +::-webkit-scrollbar-thumb { + background: #4f46e5; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4338ca; +} \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..85ecbae --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,11 @@ +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from './slices/authSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts new file mode 100644 index 0000000..33b2efa --- /dev/null +++ b/src/store/slices/authSlice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface AuthState { + authToken: string | null; + negocios_id: string | null; + negocios_nome: string | null; + user_id: string | null; + isAuthenticated: boolean; +} + +const initialState: AuthState = { + authToken: null, + negocios_id: null, + negocios_nome: null, + user_id: null, + isAuthenticated: false, +}; + +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + setAuth: (state, action: PayloadAction>) => { + return { ...state, ...action.payload }; + }, + logout: (state) => { + return initialState; + }, + }, +}); + +export const { setAuth, logout } = authSlice.actions; +export default authSlice.reducer; \ No newline at end of file diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..09b71f5 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,20 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Scrollbar personalizada */ +::-webkit-scrollbar { + @apply w-2; +} + +::-webkit-scrollbar-track { + @apply bg-gray-900; +} + +::-webkit-scrollbar-thumb { + @apply bg-indigo-600 rounded-md; +} + +::-webkit-scrollbar-thumb:hover { + @apply bg-indigo-700; +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..4d80f5b --- /dev/null +++ b/styles.css @@ -0,0 +1,103 @@ +/* Estilos gerais */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + } + + /* Container principal */ + .container { + padding: 20px; + width: 300px; + } + + /* Títulos */ + h2 { + text-align: center; + } + + /* Formulários */ + form label { + display: block; + margin-top: 10px; + } + + form input[type="text"], + form select, + form textarea { + width: 100%; + padding: 5px; + box-sizing: border-box; + } + + form button { + margin-top: 15px; + width: 100%; + padding: 10px; + background-color: #0073b1; + color: #fff; + border: none; + cursor: pointer; + } + + form button:hover { + background-color: #005582; + } + + /* Estilos para o popup */ + .popup { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 999; + } + + .popup-content { + background-color: #fff; + padding: 20px; + border-radius: 5px; + text-align: center; + width: 80%; + max-width: 300px; + } + + .popup-content p { + font-size: 16px; + margin-bottom: 20px; + } + + /* Botões do popup */ + .popup-buttons { + display: flex; + justify-content: space-around; + margin-top: 20px; + } + + .popup-buttons button { + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + #cancelButton { + background-color: #ccc; + border: none; + border-radius: 5px; + } + + #viewButton { + background-color: #0073b1; + color: #fff; + border: none; + border-radius: 5px; + } + + #viewButton:hover { + background-color: #005582; + } \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..73f78ae --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{html,js,ts,jsx,tsx}", + "./*.html" + ], + theme: { + extend: {}, + }, +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0e621e8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: { + popup: resolve(__dirname, 'index.html'), + background: resolve(__dirname, 'src/background/index.ts'), + content: resolve(__dirname, 'src/content/index.ts') + }, + output: { + entryFileNames: (chunkInfo) => { + return chunkInfo.name === 'background' ? 'background.js' : + chunkInfo.name === 'content' ? 'content.js' : + '[name].js'; + }, + assetFileNames: (assetInfo) => { + const info = assetInfo.name.split('.'); + const extType = info[info.length - 1]; + + if (extType === 'css') { + return 'content.css'; + } + + return `assets/[name][extname]`; + }, + }, + }, + }, +}); \ No newline at end of file