18 KiB
name, description
| name | description |
|---|---|
| proposta-visual | Criação completa de propostas comerciais visuais — página web (propostas.descomplicar.pt) + PDF exportável + PPTX editável. Recolhe dados CRM, gera JSON, renderiza condicionalmente e faz deploy. |
Skill /proposta-visual — Propostas Comerciais Visuais
Cria propostas comerciais profissionais com 3 outputs: página web interactiva, PDF exportável e PPTX editável.
Triggers
/proposta-visual- "proposta visual", "proposta web", "criar proposta", "nova proposta", "proposta para [cliente]"
Quando usar
- Criar proposta comercial completa para um cliente
- Gerar página web + PDF + PPTX de uma proposta
- Actualizar proposta existente com novos dados
Quando NÃO usar
- Apenas orçamento sem visual (usar
/orcamento) - Apenas PPTX sem página web (usar
/proposal-deck) - Análise de lead sem proposta (usar
/lead-approach)
Projecto base
| Campo | Valor |
|---|---|
| Código fonte | /media/ealmeida/Dados/Dev/Propostas/ |
| Stack | React 18 + Vite + Tailwind + @react-pdf/renderer + Recharts + Framer Motion + Lucide React |
| Live | propostas.descomplicar.pt/{slug} |
| Deploy | Docker Swarm EasyPanel, serviço descomplicar_propostas, ficheiros /opt/propostas/ |
| DNS | propostas.descomplicar.pt → 5.9.90.70 (Cloudflare proxied) |
| Template PPTX | /media/ealmeida/Dados/Hub/90-Templates/Comercial/descomplicar-proposal-template.pptx |
REGRAS CRÍTICAS (ler antes de qualquer implementação)
1. PT-PT obrigatório — acentos em TODO o texto
Todos os textos no JSON, componentes web e componentes PDF DEVEM ter acentos PT-PT correctos:
- ✓ migração, tradução, integração, opções, condições, execução, acção
- ✗ migracao, traducao, integracao, opcoes, condicoes, execucao, accao
Verificar no PDF: labels hardcoded em PDFWorkDone.jsx e PDFDocument.jsx — já corrigidos mas confirmar sempre.
2. JSON data-driven — renderização condicional obrigatória
O ProposalView.jsx e o PDFDocument.jsx renderizam secções condicionalmente com base nos dados. Se um campo é null, [] ou não existe, a secção NÃO aparece.
Regra: NUNCA assumir que todos os campos existem. Cada proposta pode ter um subconjunto diferente de secções.
| Campo JSON | Secção web | Secção PDF | Condição |
|---|---|---|---|
context.brandStrategy |
BrandStrategy | PageBrandStrategy | != null |
salesData |
SalesChart | PageSales | .length > 0 |
context.diagnosis |
Context | — | .length > 0 |
context.scores |
ScoreGrid | PDFScores | .length > 0 |
service360 |
Service360 | PageService360 | != null |
plans |
PricingCards | PDFPricing | .length > 0 |
licenses |
LicensePack | PageLicenses | != null |
workDone |
WorkDone | PDFWorkDone | .length > 0 |
roadmap |
Roadmap | PDFWorkDone | .length > 0 |
conditions |
Conditions | PDFWorkDone (pág. separada) | .length > 0 |
3. Título dinâmico — campo title no JSON
O título da proposta (Hero web + capa PDF) vem do campo title no JSON:
- Proposta 360°:
"title": "Descomplicar 360°" - Migração técnica:
"title": "Migração Bookeo" - Website:
"title": "Website Profissional" - Fallback:
"Proposta Comercial"
NUNCA hardcodar "Descomplicar 360°" — usar data.title.
4. Preços flexíveis — não assumir mensalidades
Os planos suportam dois formatos de preço:
Mensalidade (avença):
{
"monthly": 459,
"hours": 8,
"originalPrice": 540,
"discount": 15
}
Mostra: "459 €/mês" + "8h/mês incluídas · contrato anual"
Preço único (investimento):
{
"price": 750,
"priceLabel": "EUR + IVA",
"priceSubtitle": "Investimento único · prazo 1-2 semanas",
"monthly": null,
"hours": null
}
Mostra: "750 EUR + IVA" + "Investimento único · prazo 1-2 semanas"
Campos usados: plan.price ?? plan.monthly para o valor, plan.priceLabel ?? "€/mês" para a unidade, plan.priceSubtitle para a descrição abaixo.
5. react-pdf — bugs conhecidos e PROIBIÇÕES
| Proibição | Porquê | Alternativa |
|---|---|---|
gap + flexWrap juntos |
Crasha silenciosamente | marginRight + marginBottom nos items |
borderColor com rgba() |
Renderiza verde | Cor sólida (ex: #D4A020, #E8E8E8) |
Import de componentes SVG (<Svg>, <Circle>) |
Crasha web worker do PDFDownloadLink | Implementar gráficos inline com <View> |
Font.register com fontes locais/CDN em multi-página |
Falha silenciosa | Usar Helvetica/Helvetica-Bold (built-in) |
href vazio no PDFDownloadLink |
PDF não gerou — verificar consola | Cache do browser — Ctrl+Shift+R |
width: "100%" em bars/elementos dentro de flex container |
Não resolve — fica invisível | Usar alignSelf: "stretch" no elemento e no container pai |
Imagens com path absoluto (/home/...) |
react-pdf não acede ao filesystem local | Colocar em public/assets/ e referenciar como "assets/foto.png" (sem / inicial) |
borderBottomWidth no header + borderTopWidth em componente filho |
Duplica a linha dourada visualmente | Usar apenas borderTopWidth: 2 no próprio header; remover borderBottom |
Hifenização: Para desactivar em todo o PDF, adicionar ao <Document>:
hyphenationCallback={(word) => [word]}
Ícones no PDF: Usar PDFIcons.jsx (src/pdf/sections/) com Svg/Path/Circle/Line/Polyline de @react-pdf/renderer. NÃO importar lucide-react nos componentes PDF — crasha o worker.
Ícones na web: Usar lucide-react. Padrão: FALLBACK_ICONS[i % FALLBACK_ICONS.length] para rotação automática por índice quando o JSON não tem campo icon.
6. Página 360° (service360) — contextualizar
A secção service360 no PDF usa o label "O QUE PROPOMOS" (genérico) e o título "As nossas soluções para o seu negócio". Os dados vêm do JSON service360.areas[].
Para propostas que NÃO são 360°, adaptar os areas ao contexto:
- Migração técnica: features da migração
- Website: áreas do serviço web
- SEO: pilares do trabalho SEO
Se service360 for null, a secção não aparece.
7. Condições comerciais — página separada no PDF
As condições estão numa página PDF separada (não na mesma que o roadmap) para evitar corte entre páginas. O componente PDFWorkDone retorna um Fragment com 2 Pages.
8. Apresentação 16:9 — PDFPresentation.jsx
Existe um segundo output PDF em formato 1920×1080 (slides), activado por um botão "Apresentação 16:9" no canto inferior direito da proposta web.
Ficheiros envolvidos:
src/pdf/PDFPresentation.jsx— documento completo com todos os slidessrc/pdf/sections/PDFSlide.jsx— wrapper de slide (Page 1440×810pt, header com logo, rodapé com numeração)
Dimensões: 1920×1080 @ 72dpi → [1440, 810] em pontos (unidade do react-pdf).
Estrutura de slides:
| Slide | Componente | Dados |
|---|---|---|
| Capa | SlideCover |
data.title, client, date, company |
| Diagnóstico | SlideProblem |
context.description, context.diagnosis |
| Descomplicar 360° | Slide360 |
educationSections com label:"Descomplicar 360°" |
| Educação | SlideEducation |
restantes educationSections |
| Soluções | SlideService360 |
service360.areas |
| Previsão de leads | SlideLeadForecast |
leadForecast |
| Preços | SlidePricing |
plans |
| Roadmap | SlideRoadmap |
roadmap |
| Contacto | SlideContact |
company, client.contact |
Helper components internos (PDFPresentation.jsx):
GoldLine— linha dourada decorativaTag— label uppercase dourado (fontSize 13)SlideTitle— título principal do slide (fontSize 52 default)SlideBody— corpo do slide (fontSize 19)Card— caixa de conteúdo com border esquerda dourada (title 16, body 14)CardGrid— wrapper flexWrap para cards
Tamanhos de referência para legibilidade em sala:
- Tag: 13pt | SlideTitle: 42-58pt | SlideBody: 19pt
- Card title: 16pt | Card body: 14pt
- Cover logo: height 120 (3× maior que header normal)
Separação Slide360 vs SlideEducation:
const slide360 = educationSections?.find(s => s.label?.toLowerCase().includes("360"))
const otherSections = educationSections?.filter(s => !s.label?.toLowerCase().includes("360"))
Botão no App.jsx:
<PDFDownloadLink
document={<PDFPresentation data={currentProposal} />}
fileName={`apresentacao-descomplicar-${...}.pdf`}
>
{({ loading }) => <button>Apresentação 16:9</button>}
</PDFDownloadLink>
PROIBIÇÕES específicas do 16:9:
- NÃO usar
gapemflexWrap(mesmo bug do A4) - NÃO usar
rgba()emborderColor - Manter
hyphenationCallbackno<Document>do PDFPresentation também
Protocolo
Sintaxe
/proposta-visual [cliente] [tipo-serviço]
Fase 1: Análise do contexto
ANTES de criar o JSON, responder a estas perguntas:
- Que tipo de proposta é? (360°, website, migração técnica, SEO, eCommerce, outro)
- O preço é mensal ou único? → define
monthlyvsprice/priceLabel/priceSubtitle - Que secções se aplicam? → define que campos preencher no JSON
- Há dados quantitativos? (vendas, scores SEO, KPIs) → define
salesData,context.scores - Há trabalho já realizado? → define
workDone - Quantas opções de preço? → define
plans[]
Mapa de secções por tipo de proposta:
| Secção | 360° | Website | Migração | SEO | eCommerce |
|---|---|---|---|---|---|
title |
Descomplicar 360° | Website Profissional | [contexto] | Optimização SEO | Loja Online |
brandStrategy |
✓ | Opcional | — | — | Opcional |
salesData |
Se existir | — | — | — | Se existir |
scores |
✓ (audit) | Opcional | — | ✓ (audit) | Opcional |
service360 |
✓ | ✓ | ✓ | ✓ | ✓ |
plans |
✓ (3 opções) | ✓ | ✓ | ✓ | ✓ |
licenses |
Se aplicável | — | — | — | Se aplicável |
workDone |
Se existir | Se existir | ✓ | Se existir | Se existir |
roadmap |
✓ | ✓ | ✓ | ✓ | ✓ |
conditions |
✓ | ✓ | ✓ | ✓ | ✓ |
Fase 2: Recolha de dados do CRM
mcp__desk-crm-v3__search_customers query="[nome]"
mcp__desk-crm-v3__get_customer customer_id=[id]
mcp__desk-crm-v3__get_estimates client_id=[id]
Fase 3: Criar ficheiro JSON
Localização: src/data/[slug].json
Gerar slug: iniciais do cliente + tipo + hash curto. Ex: ccv-bookeo-a3f7c1d2
Schema mínimo obrigatório:
{
"title": "Título da Proposta",
"client": {
"name": "Nome Legal Lda.",
"brand": "Nome Comercial",
"website": "exemplo.pt",
"contact": "Nome Contacto",
"email": "email@exemplo.pt",
"phone": "+351 900 000 000"
},
"company": {
"name": "Descomplicar, Lda.",
"brand": "Descomplicar",
"nif": "514 785 691",
"email": "info@descomplicar.pt",
"phone": "911 510 005",
"website": "descomplicar.pt"
},
"date": "2026-03-23",
"validDays": 30,
"context": {
"description": "Descrição da situação...",
"diagnosis": [],
"scores": [],
"brandStrategy": null
},
"service360": null,
"plans": [],
"licenses": null,
"workDone": [],
"roadmap": [],
"conditions": [],
"salesData": [],
"salesProjection": []
}
Campos opcionais para planos com preço único:
{
"price": 750,
"priceLabel": "EUR + IVA",
"priceSubtitle": "Investimento único · prazo 1-2 semanas",
"monthly": null,
"hours": null
}
Checklist PT-PT antes de guardar o JSON:
- Todos os textos têm acentos correctos (ã, ç, ã, é, ê, í, ó, õ, ú)
- Zero brasileirismos
- Caractere → (seta) em vez de -> nos textos visíveis
- Monetário: "EUR" ou "€" (não "R$" nem "$")
Fase 4: Registar no App.jsx
import novaData from "./data/[slug].json"
const proposals = {
// existentes...
"[novo-slug]": novaData,
}
Fase 5: Verificar antes do build
Checklist pré-build:
- JSON válido (sem trailing commas, sem campos undefined)
- Campos null/[] para secções que não se aplicam (NÃO omitir)
titledefinido (não depender do fallback)plans[].priceOUplans[].monthlydefinido (não ambos null)plans[].priceLabeldefinido se preço único- Slug registado em App.jsx
- Nenhum
flexWrap+gapnos componentes PDF - Nenhum
borderColorcomrgba()nos componentes PDF
Fase 6: Build e deploy
cd /media/ealmeida/Dados/Dev/Propostas
npm run build
# Limpar dist
cd dist
rm -rf assets/template-media assets/template-referencia.pptx assets/slide-referencia.svg
cd assets/brochura && ls *.{jpg,png} 2>/dev/null | grep -v image8.jpg | xargs rm -f 2>/dev/null
# Empacotar
cd /media/ealmeida/Dados/Dev/Propostas/dist
tar czf /tmp/propostas-dist.tar.gz .
# Upload + deploy
mcp__ssh-unified__sftp_upload server=easy localPath=/tmp/propostas-dist.tar.gz remotePath=/tmp/propostas-dist.tar.gz overwrite=true
mcp__ssh-unified__ssh_execute server=easy command="cd /opt/propostas && rm -rf assets/* index.html; tar xzf /tmp/propostas-dist.tar.gz && docker service update --force descomplicar_propostas && rm -f /tmp/propostas-dist.tar.gz"
# Verificar
curl -sI https://propostas.descomplicar.pt/[slug] | head -3
# Deve retornar HTTP/2 200
# Limpar
rm -f /tmp/propostas-dist.tar.gz
Fase 7: Testar PDF
OBRIGATÓRIO antes de entregar:
- Abrir
https://propostas.descomplicar.pt/[slug]com Ctrl+Shift+R - Clicar "Exportar PDF"
- Verificar que
hrefdo link não está vazio (PDF gerou) - Abrir PDF e verificar:
- Título correcto (não "Descomplicar 360°" hardcoded)
- Preços correctos (não "€/mês" se é preço único)
- Sem borders verdes (rgba renderiza verde no react-pdf)
- Sem secções que não se aplicam (360°, vendas, licenças, scores)
- Acentos PT-PT em todo o texto
- Condições comerciais numa página inteira (sem corte)
- Todas as 3 opções de preço com layout uniforme
Se o PDF não gera (href vazio):
- Verificar consola do browser (F12)
- Causa mais provável: import de componente SVG dos infographics
- Verificar que PDFDocument não importa de
./infographics/ - Verificar que não há
gap+flexWrapno PDF - Ctrl+Shift+R e tentar novamente (cache)
Fase 8: Entrega e registo
- Guardar em
Hub/03-Propostas/[Cliente]/ - Registar estimate no CRM (se não existe)
- Comunicar URL ao utilizador
- Comentar tarefa CRM
Design system ACIDA 2.0
| Token | Valor |
|---|---|
| Dourado | #C88900 |
| Dourado claro | #EED59F |
| Dourado border PDF | #D4A020 (usar em vez de rgba no PDF) |
| Dark | #262626 |
| Background | #F8F8F8 |
| Fonte web | Inter (Google Fonts) |
| Fonte PDF | Helvetica / Helvetica-Bold |
| Logo escuro | /assets/logo-descomplicar.png |
| Logo claro | /assets/logo-descomplicar-white.png |
Propostas existentes
| Slug | Cliente | Tipo | Data |
|---|---|---|---|
ljm-360-61a8aecc |
A Loja da Maria | 360° (mensal) | 2026-03-20 |
ccv-bookeo-a3f7c1d2 |
Carvoeiro Caves | Migração técnica (único) | 2026-03-23 |
pbk-autoridade-local-2026 |
Equipa Sérgio Oliveira e Paula Barros · KW Portugal | SEO + conteúdo hiperlocal (mensal) | 2026-04-27 |
Skills relacionadas
/proposal-deck— PPTX standalone (16 layouts, XML editing)/orcamento— Estimate no CRM (sem visual)/lead-approach— Estratégia de abordagem de lead/crm— Operações CRM genéricas
Changelog
v2.0.0 (2026-03-23)
- Regras críticas documentadas (7 regras)
- Renderização condicional obrigatória (ProposalView + PDFDocument)
- Título dinâmico via campo
titleno JSON - Preços flexíveis (mensal vs único) com
price/priceLabel/priceSubtitle - Bugs react-pdf documentados com proibições explícitas
- Mapa de secções por tipo de proposta
- Checklist pré-build e teste PDF obrigatório
- Condições comerciais em página PDF separada
v2.2.0 (2026-04-27)
- Formato 16:9 documentado:
PDFPresentation.jsx+PDFSlide.jsx - Regra 8 adicionada: estrutura de slides, dimensões, helper components, tamanhos de referência
- Separação Slide360 vs SlideEducation por label
- Botão "Apresentação 16:9" no App.jsx documentado
- Proibições específicas 16:9 adicionadas
v2.1.0 (2026-04-27)
- Novos bugs react-pdf documentados:
width:"100%"em flex, paths absolutos em imagens,borderBottomduplicado - Hifenização:
hyphenationCallbackno<Document> - Padrão de ícones:
PDFIcons.jsxpara PDF, lucide-react para web, fallback por índice - Proposta
pbk-autoridade-local-2026adicionada ao registo - Healing Log com 5 erros reais corrigidos
v1.0.0 (2026-03-23)
- Versão inicial
Versão: 2.2.0 | Data: 2026-04-27
Healing Log
Registo de erros conhecidos e como evitá-los. Lido automaticamente antes de executar.
{"date":"2026-04-27","issue":"Barras de gráfico invisíveis no PDF","fix":"width:'100%' não funciona em flex containers — substituir por alignSelf:'stretch' no container e na barra","source":"user"}
{"date":"2026-04-27","issue":"Linha dourada duplicada no topo das páginas","fix":"PDFPage tinha borderBottomWidth no header E os componentes filhos tinham borderTopWidth — remover borderBottom do header, manter apenas borderTopWidth:2","source":"user"}
{"date":"2026-04-27","issue":"Foto do gestor de conta não aparece no PDF","fix":"react-pdf não carrega paths absolutos do filesystem — copiar para public/assets/ e referenciar sem / inicial","source":"user"}
{"date":"2026-04-27","issue":"Border verde em vez de dourado no PDF","fix":"rgba() em borderColor renderiza verde — usar hex sólido (#D4A020)","source":"user"}
{"date":"2026-04-27","issue":"Hifenização indesejada no PDF","fix":"Adicionar hyphenationCallback={(word) => [word]} ao <Document>","source":"user"}
Adicionar nova linha após cada erro corrigido.