initial commit

This commit is contained in:
Lucas Santana 2024-12-17 18:50:58 -03:00
commit f7fb7f963c
49 changed files with 1674 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

181
background.js Normal file
View File

@ -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;
}
});

60
content.css Normal file
View File

@ -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);
}
}

74
content.js Normal file
View File

@ -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();

BIN
icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

BIN
icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

BIN
icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

BIN
images/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
images/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

BIN
images/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

BIN
images/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

BIN
images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Launchr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/popup/index.tsx"></script>
</body>
</html>

0
index.js Normal file
View File

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

38
manifest.json Normal file
View File

@ -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"
}
}

24
menu.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Login</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h2>Login</h2>
<form id="loginForm">
<label for="emailInput">E-mail:</label>
<input type="email" id="emailInput" name="email" required>
<label for="passwordInput">Senha:</label>
<input type="password" id="passwordInput" name="password" required>
<button type="submit">Entrar</button>
</form>
</div>
<script src="menu.js"></script>
</body>
</html>

23
menu.js Normal file
View File

@ -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.');
}
});
});

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"watch": "vite build --watch"
}
}

53
popup.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Salvar Post</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h2>Salvar Post</h2>
<form id="postForm">
<label for="author">Autor:</label>
<input type="text" id="author" name="author" readonly>
<label for="authorProfileLink">Link do Perfil do Autor:</label>
<input type="text" id="authorProfileLink" name="authorProfileLink" readonly>
<label for="postLink">Link do Post:</label>
<input type="text" id="postLink" name="postLink" readonly>
<label for="date">Data:</label>
<input type="text" id="date" name="date" readonly>
<label for="content">Conteúdo:</label>
<textarea id="content" name="content" rows="4" readonly></textarea>
<label for="category">Categoria:</label>
<select id="category" name="category">
<option value="">Selecione uma categoria</option>
<option value="Marketing">Marketing</option>
<option value="Vendas">Vendas</option>
<option value="Tecnologia">Tecnologia</option>
<!-- Adicione outras categorias conforme necessário -->
</select>
<button type="button" id="saveButton">Salvar</button>
</form>
</div>
<!-- Popup de confirmação -->
<div id="successPopup" class="popup" style="display: none;">
<div class="popup-content">
<p>O post foi enviado com sucesso!</p>
<div class="popup-buttons">
<button id="cancelButton">Fechar</button>
<button id="viewButton">Visualizar Swipe File</button>
</div>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

130
popup.js Normal file
View File

@ -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.');
}
});
}
});
}
});

34
scripts/copy-assets.js Normal file
View File

@ -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);
}
});

View File

@ -0,0 +1,19 @@
import sharp from 'sharp';
import { resolve } from 'path';
// Criar um ícone SVG simples
const svgIcon = `
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" fill="#4F46E5"/>
<text x="64" y="64" font-family="Arial" font-size="80" fill="white" text-anchor="middle" dominant-baseline="middle">
L
</text>
</svg>
`;
// 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));

View File

@ -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));
});

BIN
src/assets/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
src/assets/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

BIN
src/assets/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

BIN
src/assets/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

123
src/background/index.ts Normal file
View File

@ -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<boolean> => {
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' });
}
};

View File

@ -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 (
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-bold mb-4">Faça o seu login</h2>
<h4 className="text-gray-600 mb-6">Preencha suas informações e faça o login no Launchr</h4>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => 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
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => 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
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Entrando...' : 'Entrar'}
</button>
</form>
</div>
);
};
export default LoginForm;

53
src/components/Menu.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm fixed w-full top-0 z-50">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex">
<img
src="/assets/logo.png"
alt="Launchr Logo"
className="h-10 w-auto self-center"
/>
</div>
</div>
</div>
</nav>
<main className="mt-16 p-4">
{isAuthenticated ? <Dashboard /> : <LoginForm />}
</main>
</div>
);
};
export default Menu;

View File

@ -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);
}
}

View File

@ -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 (
<button
onClick={handleClick}
disabled={status !== 'idle'}
className={`launchr-save-button ${status}`}
>
{getButtonText()}
</button>
);
};
export default SaveButton;

67
src/content/index.css Normal file
View File

@ -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;
}

0
src/content/index.js Normal file
View File

72
src/content/index.ts Normal file
View File

@ -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(
<SaveButton
onSave={async () => {
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
});

67
src/content/style.css Normal file
View File

@ -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;
}

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

44
src/popup/Router.tsx Normal file
View File

@ -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 (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
);
}
return isAuthenticated ? <Dashboard /> : <LoginForm />;
};
export default Router;

14
src/popup/index.tsx Normal file
View File

@ -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(
<React.StrictMode>
<Provider store={store}>
<Router />
</Provider>
</React.StrictMode>
);

128
src/popup/styles.css Normal file
View File

@ -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;
}

11
src/store/index.ts Normal file
View File

@ -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<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -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<Partial<AuthState>>) => {
return { ...state, ...action.payload };
},
logout: (state) => {
return initialState;
},
},
});
export const { setAuth, logout } = authSlice.actions;
export default authSlice.reducer;

20
src/styles.css Normal file
View File

@ -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;
}

103
styles.css Normal file
View File

@ -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;
}

10
tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,js,ts,jsx,tsx}",
"./*.html"
],
theme: {
extend: {},
},
}

35
vite.config.ts Normal file
View File

@ -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]`;
},
},
},
},
});