Compare commits

..

7 Commits

Author SHA1 Message Date
241ca3560d Merge pull request 'Release/ArtigosPDF' (#21) from Release/ArtigosPDF into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 6s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m26s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m5s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/21
2025-11-07 14:55:48 +00:00
37cd753a6a fix: links
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 7s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 3s
BCards Deployment Pipeline / Build and Push Image (pull_request) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (pull_request) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 1s
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 1s
BCards Deployment Pipeline / Build and Push Image (push) Successful in 6m50s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 10s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-11-04 22:42:07 -03:00
b6a5329a6b fix: planos 2025-11-03 00:04:41 -03:00
230c6a958d feat: upload de PDF 2025-11-03 00:04:16 -03:00
0803a3bcc9 fix: ajustes de preço 2025-11-02 21:42:31 -03:00
2f8f19d16d feat: artigos & tutoriais 2025-11-02 20:56:04 -03:00
b382688a8f feat: fale conosco
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 4s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 11m18s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 17s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-10-28 19:58:43 -03:00
73 changed files with 9135 additions and 346 deletions

View File

@ -30,7 +30,11 @@
"Bash(ssh:*)", "Bash(ssh:*)",
"Bash(cat:*)", "Bash(cat:*)",
"Bash(dig:*)", "Bash(dig:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"Bash(netstat:*)",
"Bash(ss:*)",
"Bash(lsof:*)",
"Bash(dotnet run:*)"
] ]
}, },
"enableAllProjectMcpServers": false "enableAllProjectMcpServers": false

36
AGENTS.md Normal file
View File

@ -0,0 +1,36 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/BCards.Web` is the main MVC app (Controllers, Services, Repositories, Razor Views, `wwwroot` assets).
- `src/BCards.IntegrationTests` spins up the site with fixtures for API-level checks.
- `tests/BCards.Tests` hosts xUnit + Moq unit coverage with overrides in `appsettings.Testing.json`.
- `tests.e2e` carries Playwright specs and config; utility scripts live under `scripts/`, with `clean-build.sh` mirroring CI cleanup.
## Build, Test & Development Commands
- `dotnet restore && dotnet build BCards.sln` primes dependencies and compiles.
- `dotnet run --project src/BCards.Web` launches the site (HTTPS on 5001 by default).
- `dotnet test` executes unit + integration suites; add `--collect:"XPlat Code Coverage"` to emit coverlet results.
- In `tests.e2e`, run `npm install` once and `npx playwright test` per change; append `--headed` when debugging flows.
- `./clean-build.sh` removes stale `bin/obj` output before CI or release builds.
## Coding Style & Naming Conventions
- Use 4-space indents, file-scoped namespaces, PascalCase for types, camelCase for locals, and `_camelCase` for DI fields.
- Keep Razor views presentation-only; push logic into Services, ViewModels, or TagHelpers.
- Store localization strings in `Resources/`, shared UI in `Views/Shared`, and bundle-ready assets in `wwwroot`.
- Run `dotnet format` before pushing; .NET 8 analyzers treat warnings as errors in the pipeline.
## Testing Guidelines
- Mirror namespaces when creating unit files (`FooServiceTests` for `FooService`) and favour FluentAssertions for expressiveness.
- Integration scenarios reside in `src/BCards.IntegrationTests/Tests`; use shared fixtures to mock MongoDB/Stripe without polluting global state.
- End-to-end cases focus on signup, checkout, and profile rendering; keep snapshots in `tests.e2e/debug_*`.
- Target ≥80% coverage across `Services/` and `Repositories/`; call out gaps explicitly in the PR body.
## Commit & Pull Request Guidelines
- Follow the observed `type: resumo` format (`feat: artigos & tutoriais`, `fix: checkout`); keep scopes short, Portuguese when public-facing.
- Squash WIP branches before review; one functional change per commit.
- Every PR needs a summary, verification list (`dotnet test`, Playwright when touched), related issue link, and UI artifacts when visuals change.
- Tag a module expert for review and flip the `Ready for QA` label only after E2E automation passes.
## Security & Configuration Notes
- Keep secrets out of version control; base new configs on `appsettings.Production.example.json` and document required keys.
- When callback URLs move, update both the environment files (`Dockerfile`, `docker-compose*.yml`) and external provider dashboards together.

View File

@ -0,0 +1,740 @@
[{
"_id": {
"$oid": "685b17a70138fec28779d354"
},
"name": "Corretor de Imóveis",
"slug": "corretor",
"icon": "🏠",
"seoKeywords": [
"corretor",
"imóveis",
"casa",
"apartamento",
"venda",
"locação"
],
"description": "Profissionais especializados em compra, venda e locação de imóveis",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:24:55.336Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d355"
},
"name": "Tecnologia",
"slug": "tecnologia",
"icon": "💻",
"seoKeywords": [
"desenvolvimento",
"software",
"programação",
"tecnologia",
"TI"
],
"description": "Empresas e profissionais de tecnologia, desenvolvimento e TI",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.709Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d356"
},
"name": "Saúde",
"slug": "saude",
"icon": "🏥",
"seoKeywords": [
"médico",
"saúde",
"clínica",
"consulta",
"tratamento"
],
"description": "Profissionais da saúde, clínicas e consultórios médicos",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.713Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d357"
},
"name": "Educação",
"slug": "educacao",
"icon": "📚",
"seoKeywords": [
"educação",
"ensino",
"professor",
"curso",
"escola"
],
"description": "Professores, escolas, cursos e instituições de ensino",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.717Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d358"
},
"name": "Comércio",
"slug": "comercio",
"icon": "🛍️",
"seoKeywords": [
"loja",
"comércio",
"venda",
"produtos",
"e-commerce"
],
"description": "Lojas, e-commerce e estabelecimentos comerciais",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.720Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d359"
},
"name": "Serviços",
"slug": "servicos",
"icon": "🔧",
"seoKeywords": [
"serviços",
"prestador",
"profissional",
"especializado"
],
"description": "Prestadores de serviços gerais e especializados",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.723Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35a"
},
"name": "Alimentação",
"slug": "alimentacao",
"icon": "🍽️",
"seoKeywords": [
"restaurante",
"comida",
"delivery",
"alimentação",
"gastronomia"
],
"description": "Restaurantes, delivery, food trucks e estabelecimentos alimentícios",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.727Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35b"
},
"name": "Beleza",
"slug": "beleza",
"icon": "💄",
"seoKeywords": [
"beleza",
"salão",
"estética",
"cabeleireiro",
"manicure"
],
"description": "Salões de beleza, barbearias, estética e cuidados pessoais",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.731Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35c"
},
"name": "Advocacia",
"slug": "advocacia",
"icon": "⚖️",
"seoKeywords": [
"advogado",
"jurídico",
"direito",
"advocacia",
"legal"
],
"description": "Advogados, escritórios jurídicos e consultoria legal",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.734Z"
}
},
{
"_id": {
"$oid": "685b17c20138fec28779d35d"
},
"name": "Arquitetura",
"slug": "arquitetura",
"icon": "🏗️",
"seoKeywords": [
"arquiteto",
"engenheiro",
"construção",
"projeto",
"reforma"
],
"description": "Arquitetos, engenheiros e profissionais da construção",
"isActive": true,
"createdAt": {
"$date": "2025-06-24T21:25:22.737Z"
}
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6933"
},
"name": "Artesanato",
"slug": "artesanato",
"icon": "🎨",
"seoKeywords": [
"artesanato",
"artesão",
"feito à mão",
"personalizado",
"criativo",
"decoração"
],
"description": "Artesãos e criadores de produtos feitos à mão, decoração e arte personalizada",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6934"
},
"name": "Papelaria",
"slug": "papelaria",
"icon": "📝",
"seoKeywords": [
"papelaria",
"escritório",
"material escolar",
"impressão",
"convites",
"personalização"
],
"description": "Lojas de papelaria, material de escritório, impressão e produtos personalizados",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6935"
},
"name": "Coaching",
"slug": "coaching",
"icon": "🎯",
"seoKeywords": [
"coaching",
"mentoria",
"desenvolvimento pessoal",
"life coach",
"business coach",
"liderança"
],
"description": "Coaches, mentores e profissionais de desenvolvimento pessoal e empresarial",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6936"
},
"name": "Fitness",
"slug": "fitness",
"icon": "💪",
"seoKeywords": [
"fitness",
"academia",
"personal trainer",
"musculação",
"treinamento",
"exercícios"
],
"description": "Personal trainers, academias, estúdios de pilates e profissionais fitness",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6937"
},
"name": "Psicologia",
"slug": "psicologia",
"icon": "🧠",
"seoKeywords": [
"psicólogo",
"terapia",
"psicologia",
"saúde mental",
"consultório",
"atendimento"
],
"description": "Psicólogos, terapeutas e profissionais de saúde mental",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6938"
},
"name": "Nutrição",
"slug": "nutricao",
"icon": "🥗",
"seoKeywords": [
"nutricionista",
"dieta",
"nutrição",
"alimentação saudável",
"consultoria nutricional",
"emagrecimento"
],
"description": "Nutricionistas, consultores em alimentação e profissionais da nutrição",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6939"
},
"name": "Moda e Vestuário",
"slug": "moda",
"icon": "👗",
"seoKeywords": [
"moda",
"vestuário",
"roupas",
"fashion",
"estilista",
"costureira"
],
"description": "Lojas de roupas, estilistas, costureiras e profissionais da moda",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693a"
},
"name": "Fotografia",
"slug": "fotografia",
"icon": "📸",
"seoKeywords": [
"fotógrafo",
"fotografia",
"ensaio",
"casamento",
"eventos",
"retratos"
],
"description": "Fotógrafos profissionais, estúdios fotográficos e serviços de fotografia",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693b"
},
"name": "Marketing Digital",
"slug": "marketing-digital",
"icon": "📱",
"seoKeywords": [
"marketing digital",
"social media",
"publicidade",
"SEO",
"gestão de redes",
"digital"
],
"description": "Agências de marketing digital, gestores de redes sociais e consultores digitais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693c"
},
"name": "Contabilidade",
"slug": "contabilidade",
"icon": "📊",
"seoKeywords": [
"contador",
"contabilidade",
"fiscal",
"imposto de renda",
"MEI",
"consultoria contábil"
],
"description": "Contadores, escritórios contábeis e consultoria fiscal",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693d"
},
"name": "Design",
"slug": "design",
"icon": "🎨",
"seoKeywords": [
"designer",
"design gráfico",
"identidade visual",
"logo",
"criativo",
"branding"
],
"description": "Designers gráficos, criativos e profissionais de identidade visual",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693e"
},
"name": "Consultoria",
"slug": "consultoria",
"icon": "🤝",
"seoKeywords": [
"consultor",
"consultoria",
"assessoria",
"especialista",
"negócios",
"estratégia"
],
"description": "Consultores especializados, assessoria empresarial e serviços de consultoria",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb693f"
},
"name": "Pets",
"slug": "pets",
"icon": "🐕",
"seoKeywords": [
"veterinário",
"pet shop",
"animais",
"cuidados",
"petshop",
"adestramento"
],
"description": "Veterinários, pet shops, adestradores e serviços para animais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6940"
},
"name": "Casa e Jardim",
"slug": "casa-jardim",
"icon": "🏡",
"seoKeywords": [
"paisagismo",
"jardinagem",
"decoração",
"casa",
"jardim",
"plantas"
],
"description": "Paisagistas, jardineiros, decoradores e serviços para casa e jardim",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6941"
},
"name": "Automóveis",
"slug": "automoveis",
"icon": "🚗",
"seoKeywords": [
"mecânico",
"automóveis",
"carros",
"oficina",
"manutenção",
"peças"
],
"description": "Mecânicos, oficinas, lojas de peças e serviços automotivos",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6942"
},
"name": "Turismo",
"slug": "turismo",
"icon": "✈️",
"seoKeywords": [
"turismo",
"viagem",
"agência",
"guia turístico",
"passeios",
"hospedagem"
],
"description": "Agências de turismo, guias, pousadas e prestadores de serviços turísticos",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6943"
},
"name": "Música",
"slug": "musica",
"icon": "🎵",
"seoKeywords": [
"músico",
"professor de música",
"instrumentos",
"aulas",
"banda",
"eventos musicais"
],
"description": "Músicos, professores de música, bandas e profissionais do entretenimento",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6944"
},
"name": "Idiomas",
"slug": "idiomas",
"icon": "🗣️",
"seoKeywords": [
"professor de idiomas",
"inglês",
"espanhol",
"tradutor",
"aulas particulares",
"curso de idiomas"
],
"description": "Professores de idiomas, tradutores e escolas de línguas",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6945"
},
"name": "Limpeza",
"slug": "limpeza",
"icon": "🧽",
"seoKeywords": [
"limpeza",
"faxina",
"diarista",
"higienização",
"empresa de limpeza",
"doméstica"
],
"description": "Empresas de limpeza, diaristas e serviços de higienização",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6946"
},
"name": "Segurança",
"slug": "seguranca",
"icon": "🛡️",
"seoKeywords": [
"segurança",
"vigilante",
"porteiro",
"alarmes",
"monitoramento",
"proteção"
],
"description": "Empresas de segurança, vigilantes e serviços de proteção",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6947"
},
"name": "Eventos",
"slug": "eventos",
"icon": "🎉",
"seoKeywords": [
"eventos",
"festa",
"casamento",
"buffet",
"decoração de festas",
"cerimonial"
],
"description": "Organizadores de eventos, buffets, decoração e cerimonial",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6948"
},
"name": "Transporte",
"slug": "transporte",
"icon": "🚐",
"seoKeywords": [
"transporte",
"frete",
"mudança",
"delivery",
"motorista",
"logística"
],
"description": "Empresas de transporte, fretes, mudanças e serviços de entrega",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6949"
},
"name": "Construção",
"slug": "construcao",
"icon": "🔨",
"seoKeywords": [
"construção",
"pedreiro",
"pintor",
"eletricista",
"encanador",
"reforma"
],
"description": "Profissionais da construção civil, reformas e manutenção predial",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694a"
},
"name": "Joias e Acessórios",
"slug": "joias",
"icon": "💎",
"seoKeywords": [
"joias",
"bijuterias",
"acessórios",
"ourives",
"relógios",
"semijoias"
],
"description": "Joalherias, bijuterias, ourives e lojas de acessórios",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694b"
},
"name": "Odontologia",
"slug": "odontologia",
"icon": "🦷",
"seoKeywords": [
"dentista",
"odontologia",
"clínica dentária",
"ortodontia",
"implante",
"oral"
],
"description": "Dentistas, clínicas odontológicas e profissionais da área bucal",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694c"
},
"name": "Fisioterapia",
"slug": "fisioterapia",
"icon": "🏥",
"seoKeywords": [
"fisioterapeuta",
"fisioterapia",
"reabilitação",
"RPG",
"massagem",
"terapia"
],
"description": "Fisioterapeutas, clínicas de reabilitação e terapias corporais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694d"
},
"name": "Livraria",
"slug": "livraria",
"icon": "📚",
"seoKeywords": [
"livraria",
"livros",
"sebo",
"literatura",
"editora",
"publicação"
],
"description": "Livrarias, sebos, editoras e comércio de livros e publicações",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694e"
},
"name": "Floricultura",
"slug": "floricultura",
"icon": "🌸",
"seoKeywords": [
"floricultura",
"flores",
"buquê",
"plantas",
"arranjos",
"casamento"
],
"description": "Floriculturas, arranjos florais e comércio de plantas ornamentais",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb694f"
},
"name": "Farmácia",
"slug": "farmacia",
"icon": "💊",
"seoKeywords": [
"farmácia",
"farmacêutico",
"medicamentos",
"drogaria",
"manipulação",
"remédios"
],
"description": "Farmácias, drogarias e farmacêuticos especializados",
"isActive": true
},
{
"_id": {
"$oid": "68c1bd62b1e9f4be8ecb6950"
},
"name": "Delivery",
"slug": "delivery",
"icon": "🛵",
"seoKeywords": [
"delivery",
"entrega",
"motoboy",
"comida",
"aplicativo",
"rápido"
],
"description": "Serviços de delivery, entregadores e aplicativos de entrega",
"isActive": true
}]

View File

@ -0,0 +1,394 @@
---
title: "BCards vs LinkTree: A Alternativa Brasileira para Profissionais"
description: "Descubra por que o BCards é a melhor alternativa brasileira ao LinkTree para profissionais e empresas. Comparação completa de recursos, preços e diferenciais."
keywords: "linktree, link tree, alternativa ao linktree, melhor que linktree, bcards, cartão digital, bio links, página de links, linktree brasil, linktree brasileiro"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/artigos/bcards-vs-linktree-hero.jpg"
---
# BCards vs LinkTree: A Alternativa Brasileira para Profissionais
Se você está procurando uma alternativa ao LinkTree, você chegou ao lugar certo. O BCards é uma solução brasileira desenvolvida especificamente para profissionais e empresas que querem uma presença digital profissional sem depender de plataformas internacionais.
## O Que São Páginas de Bio Links?
Antes de compararmos, vamos entender o conceito. Ferramentas como LinkTree e BCards são páginas de "bio links" - uma única URL que centraliza todos os seus links importantes:
- Redes sociais (Instagram, LinkedIn, Facebook)
- WhatsApp para contato direto
- Site ou portfólio
- Loja online ou produtos
- Localização física
- E muito mais
A ideia é simples: ao invés de ter apenas um link na bio do Instagram, você tem uma página com todos os seus links organizados.
## Por Que Escolher uma Alternativa Brasileira ao LinkTree?
### 🇧🇷 1. Suporte em Português (de Verdade)
**LinkTree:**
- Suporte majoritariamente em inglês
- Fuso horário dos EUA
- Documentação traduzida automaticamente
**BCards:**
- Suporte 100% em português
- Atendimento no horário comercial brasileiro
- Documentação escrita para o mercado brasileiro
- Entendimento de necessidades locais
### 💰 2. Preços em Real (Sem Surpresas)
**LinkTree:**
- Preços em dólar (USD)
- Sujeito a variação cambial
- IOF em pagamentos internacionais (6.38%)
- Preço final imprevisível
**BCards:**
- Preços fixos em Real (R$)
- Sem variação cambial
- Sem taxas de IOF
- Pagamento via Pix, cartão brasileiro
**Exemplo Real:**
- LinkTree Pro: $9 USD/mês ≈ R$ 45-50 (variável)
- BCards Básico: R$ 12,90/mês (fixo)
### 🎯 3. URLs Profissionais e Semânticas
**LinkTree:**
```
linktr.ee/seunome
```
- Domínio genérico
- Não transmite profissionalismo
- Óbvio que é uma ferramenta de terceiros
**BCards:**
```
bcards.site/page/tecnologia/seunome
bcards.site/page/advocacia/drjoao
bcards.site/page/saude/drmaria
```
- URLs categorizadas por profissão
- SEO otimizado com categoria
- Aparência mais profissional
## Comparação Técnica: LinkTree vs BCards
### Recursos Disponíveis
| Recurso | LinkTree Free | LinkTree Pro ($9/mês) | BCards Grátis | BCards Básico (R$ 12,90/mês) |
|---------|---------------|----------------------|---------------|----------------------------|
| **Número de Links** | Ilimitado | Ilimitado | 5 | 15 |
| **Temas** | Básicos | Avançados | Básicos | Todos |
| **Analytics** | Básico | Detalhado | Básico | Detalhado |
| **Remover Logo** | ❌ | ✅ | ✅ (Grátis!) | ✅ |
| **URLs Customizáveis** | Limitado | ✅ | ✅ | ✅ |
| **Suporte em Português** | ❌ | ❌ | ✅ | ✅ |
| **Pagamento em Real** | ❌ | ❌ | ✅ | ✅ |
| **Categorização por Profissão** | ❌ | ❌ | ✅ | ✅ |
| **Moderação Humana** | ❌ | ❌ | ✅ | ✅ |
### Diferenciais Técnicos do BCards
#### 1. Sistema de Categorias Profissionais
O BCards organiza perfis por categoria profissional, o que oferece vantagens:
**SEO:**
- Google indexa melhor URLs categorizadas
- Buscar "advogado + cidade" pode trazer seu BCard
- Autoridade topical (especialização por nicho)
**Profissionalismo:**
- URL mostra sua área de atuação
- Facilita encontrar profissionais similares
- Networking dentro da categoria
**Exemplos:**
```
bcards.site/page/advocacia/dr-pedro-silva
bcards.site/page/tecnologia/joao-dev
bcards.site/page/saude/dra-ana-cardiologia
bcards.site/page/beleza/salao-mariana
```
#### 2. Moderação Humana (Sem Conteúdo Inapropriado)
**LinkTree:**
- Moderação automática (quando há)
- Permite conteúdo adulto em alguns planos
- Risco de associação com perfis inadequados
**BCards:**
- Moderação humana de todos os perfis
- Políticas claras de conteúdo profissional
- Ambiente seguro para profissionais sérios
- Rejeição de conteúdo inapropriado
**Por que isso importa?**
- Sua marca não fica ao lado de conteúdo duvidoso
- Transmite profissionalismo
- Ideal para advogados, médicos, contadores, etc.
#### 3. Integração com Mercado Brasileiro
**LinkTree:**
- Links para Venmo, CashApp (EUA)
- Integração com plataformas americanas
- Checkout Shopify (internacional)
**BCards:**
- Foco em WhatsApp Business (essencial no Brasil)
- Integração futura com Pix
- Mercado Livre, Mercado Pago, PagSeguro
- Google Maps para localização (crucial para negócios locais)
## Casos de Uso: Quando BCards é Melhor?
### ✅ Ideal para BCards:
1. **Advogados e Profissionais Liberais**
- URL profissional com categoria
- Moderação garante ambiente sério
- Preço acessível em Real
2. **Pequenos Negócios Locais**
- Integração com Google Maps
- WhatsApp Business como principal contato
- Preços em moeda local
3. **Profissionais de Saúde**
- Ambiente moderado e profissional
- Links para agendamento local
- Conformidade com políticas do CFM
4. **Prestadores de Serviços**
- Categorização por área
- Fácil de compartilhar localmente
- Suporte em português
### 🤔 Quando Considerar LinkTree:
1. **Influenciadores Internacionais**
- Audiência global
- Receita em dólar
- Integrações com plataformas americanas
2. **E-commerce Internacional**
- Vendas para fora do Brasil
- Shopify, Amazon global
- Múltiplas moedas
## Recursos Exclusivos do BCards
### 1. Links de Produtos com Preview Automático
Adicione links de produtos e o BCards captura automaticamente:
- Imagem do produto
- Preço
- Descrição
- Botão direto para compra
**Suportado:**
- Mercado Livre
- Shopee
- Amazon Brasil
- Lojas com Open Graph
### 2. Sistema de Preview para Moderação
Antes de seu perfil ficar público:
- Revisão humana do conteúdo
- Feedback se algo precisa ser ajustado
- Link de preview para você testar
- Aprovação rápida (geralmente em 24h)
### 3. Analytics Focado no Mercado Brasileiro
**Métricas relevantes:**
- Cliques por link
- Horários de maior acesso (fuso brasileiro)
- Origem do tráfego (Instagram, WhatsApp, etc.)
- Dispositivos (maioria mobile no Brasil)
## Preços Comparados (Detalhado)
### LinkTree
**Free:**
- Links ilimitados
- Temas básicos
- Analytics limitado
- Logo LinkTree presente
**Pro ($9 USD/mês ≈ R$ 45-50):**
- Temas premium
- Analytics avançado
- Remove logo
- Links prioritários
- **Problema:** Preço varia com dólar + IOF
**Premium ($24 USD/mês ≈ R$ 120-135):**
- Tudo do Pro
- Integrações avançadas
- Suporte prioritário
### BCards
**Gratuito:**
- 5 links
- Temas básicos
- Analytics básico
- **Sem logo BCards** (diferencial!)
**Básico (R$ 12,90/mês fixo):**
- 15 links
- Todos os temas
- Analytics detalhado
- Suporte em português
**Premium (R$ 29,90/mês fixo):**
- Links ilimitados
- Temas personalizados
- Logo personalizado
- Cores customizadas
- Suporte prioritário
- Upload de PDFs (até 5 arquivos)
**Economia Real:**
- LinkTree Pro anual: ~$108 + IOF = ~R$ 540-600
- BCards Básico anual: R$ 118,80
- **Economia: ~R$ 420/ano**
## Migração do LinkTree para BCards
### É Fácil Migrar?
**Sim! Processo simples:**
1. **Crie sua conta BCards** (2 minutos)
2. **Copie seus links do LinkTree** (5 minutos)
3. **Cole no BCards** (5 minutos)
4. **Ajuste tema e cores** (5 minutos)
5. **Atualize bio das redes sociais** (2 minutos)
**Total: ~20 minutos**
### Posso Testar Antes de Migrar?
Sim! O plano gratuito do BCards permite:
- Criar perfil completo
- Testar 5 links principais
- Ver como funciona
- Decidir se vale a pena
**Sem risco:**
- Não precisa cancelar LinkTree antes
- Teste paralelamente
- Migre quando se sentir confortável
## Perguntas Frequentes: BCards vs LinkTree
### O BCards funciona com Instagram, TikTok, YouTube?
Sim! Funciona com qualquer rede social que permite um link na bio:
- Instagram ✅
- TikTok ✅
- YouTube ✅
- LinkedIn ✅
- Facebook ✅
- Twitter/X ✅
### Posso usar domínio próprio?
**LinkTree:** Apenas em planos premium ($24/mês)
**BCards:** Em desenvolvimento (plano Premium futuro)
Atualmente: `bcards.site/page/categoria/seunome`
### E se eu já tenho linktr.ee nas minhas mídias impressas?
Você pode:
1. Manter LinkTree redirecionando para BCards temporariamente
2. Atualizar gradualmente materiais impressos
3. Usar QR Code do BCards (gera automaticamente)
### O BCards tem app mobile?
Não é necessário! É 100% web:
- Funciona perfeitamente no mobile
- Não ocupa espaço no celular
- Atualiza instantaneamente
- Sem necessidade de instalar nada
### Posso cancelar a qualquer momento?
Sim! Sem fidelidade:
- Cancele quando quiser
- Sem multa
- Sem burocracia
- Dados não são deletados (voltam ao plano grátis)
## Conclusão: Vale a Pena Trocar?
### ✅ Vale a pena trocar do LinkTree para BCards se:
- Você é profissional ou empresa brasileira
- Quer economia real (3-5x mais barato)
- Prefere suporte em português
- Busca URL mais profissional
- Quer ambiente moderado e sério
- Precisa de pagamento em Real sem surpresas
### 🤔 Talvez LinkTree seja melhor se:
- Sua audiência é majoritariamente internacional
- Você vende em dólar
- Precisa integrações específicas americanas
- Já tem grande investimento na marca "linktr.ee"
## Comece Agora
### Teste Gratuitamente
Não precisa acreditar na nossa palavra. Teste você mesmo:
1. **Crie conta grátis** (sem cartão de crédito)
2. **Configure seus 5 links principais**
3. **Escolha um tema profissional**
4. **Compartilhe e veja os resultados**
Se gostar, faça upgrade para ter mais links. Se não gostar, não paga nada.
**Pronto para dar o próximo passo?** [Criar meu BCard grátis](https://bcards.site)
---
## Comparação Rápida (TL;DR)
| Aspecto | LinkTree | BCards |
|---------|----------|--------|
| **Preço (plano básico)** | ~R$ 45-50/mês (varia) | R$ 12,90/mês (fixo) |
| **Suporte** | Inglês | Português |
| **Pagamento** | Dólar + IOF | Real (Pix/Cartão BR) |
| **URL** | linktr.ee/nome | bcards.site/categoria/nome |
| **Moderação** | Automática | Humana |
| **Foco** | Global | Brasil |
| **Remover logo (grátis)** | ❌ | ✅ |
**Economia anual:** ~R$ 420 escolhendo BCards
---
## Recursos Adicionais
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
- [BCards para Advogados](/tutoriais/advocacia/como-advogados-podem-usar-bcards)
- [Transformação Digital](/artigos/transformacao-digital-pequenos-negocios)
- [Fale com nosso suporte](/support)

View File

@ -0,0 +1,208 @@
---
title: "Transformação Digital para Pequenos Negócios: Por Onde Começar?"
description: "Descubra como a transformação digital pode revolucionar seu pequeno negócio e quais são os primeiros passos para entrar no mundo digital"
keywords: "transformação digital, pequenos negócios, digitalização, empreendedorismo, tecnologia"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/artigos/transformacao-digital-hero.jpg"
---
# Transformação Digital para Pequenos Negócios: Por Onde Começar?
A transformação digital não é mais uma opção, mas uma necessidade para pequenos negócios que querem se manter competitivos. Mas o que exatamente significa "transformação digital" e como um pequeno negócio pode embarcar nessa jornada sem gastar fortunas?
## O Que É Transformação Digital?
Transformação digital é o processo de usar tecnologias digitais para criar novos processos de negócio, cultura e experiências do cliente — ou modificar os existentes — para atender às mudanças nas demandas do mercado.
Para pequenos negócios, isso pode ser tão simples quanto:
- Ter uma presença online profissional
- Aceitar pagamentos digitais
- Usar redes sociais para marketing
- Digitalizar processos internos
- Oferecer atendimento online
## Por Que Pequenos Negócios Precisam Se Digitalizar?
### 1. Alcance Maior
Com presença digital, você não está mais limitado à sua localização física. Clientes de qualquer lugar podem encontrar e conhecer seu negócio.
### 2. Custos Menores
Marketing digital é significativamente mais barato que publicidade tradicional. Uma campanha no Instagram pode custar centenas de vezes menos que um anúncio em jornal.
### 3. Competitividade
Seus concorrentes já estão online. Não estar presente digitalmente significa perder clientes para quem está.
### 4. Conveniência para o Cliente
Clientes modernos esperam poder:
- Encontrar informações online
- Fazer pedidos pelo WhatsApp
- Pagar com Pix ou cartão
- Ver avaliações de outros clientes
## Os 5 Primeiros Passos da Transformação Digital
### 1. Crie uma Presença Digital Básica
Você não precisa de um site complexo. Comece com:
**Mínimo Necessário:**
- Uma página de links profissional (como o BCards)
- Perfil no Instagram ou Facebook
- WhatsApp Business configurado
**Por que isso funciona:**
- É gratuito ou muito barato
- Pode ser feito em 1 dia
- Já coloca você no mapa digital
### 2. Organize Seus Contatos Digitais
Centralize todos os seus pontos de contato:
- WhatsApp para pedidos
- Instagram para divulgação
- Email para orçamentos
- Link para catálogo de produtos
**Solução simples:** Use um cartão digital (BCard) que concentra todos esses links em um só lugar. Assim, você compartilha um único link e o cliente escolhe como prefere contatar você.
### 3. Aceite Pagamentos Digitais
Configure:
- Pix (essencial!)
- Maquininha de cartão
- Link de pagamento (Mercado Pago, PagSeguro)
**Resultado:** Você não perde vendas porque "o cliente não tem dinheiro trocado".
### 4. Use as Redes Sociais Estrategicamente
Não precisa estar em todas. Escolha UMA ou DUAS e faça bem:
**Para produtos físicos:** Instagram + Facebook
**Para serviços profissionais:** LinkedIn + Instagram
**Para comércio local:** Facebook + Google Meu Negócio
**Dica de ouro:** Poste regularmente (mesmo que seja 2-3x por semana). Consistência > Quantidade.
### 5. Peça Avaliações e Depoimentos
Clientes satisfeitos são seu melhor marketing. Peça:
- Avaliações no Google
- Comentários no Instagram
- Depoimentos em vídeo (WhatsApp)
**Como pedir:** "Se você ficou satisfeito com nosso serviço, pode deixar uma avaliação no Google? Isso nos ajuda muito!"
## Ferramentas Essenciais (e Baratas!)
### Gratuitas
- **Google Meu Negócio** - Apareça no Google Maps
- **WhatsApp Business** - Atendimento profissional
- **Canva** - Design de posts e materiais
- **BCards** - Página de links profissional (plano grátis disponível)
### Investimento Baixo (menos de R$ 50/mês)
- **Instagram Ads** - Impulsione suas publicações
- **Hotmart/Eduzz** - Venda produtos digitais
- **Mercado Pago** - Links de pagamento
- **BCards Premium** - Cartão digital profissional completo
## Erros Comuns a Evitar
### ❌ Erro 1: Querer Fazer Tudo de Uma Vez
Não tente criar site, app, loja virtual e estar em todas as redes sociais simultaneamente. Comece pequeno e expanda gradualmente.
### ❌ Erro 2: Comprar Ferramentas Caras Demais
Você não precisa de um site de R$ 10.000 quando está começando. Use ferramentas simples primeiro e evolua conforme cresce.
### ❌ Erro 3: Não Ter um Ponto Central
Ter Instagram, Facebook, WhatsApp é ótimo. Mas onde você manda as pessoas? Tenha UM lugar que centraliza tudo (um BCard, por exemplo).
### ❌ Erro 4: Não Medir Resultados
Use as ferramentas gratuitas de analytics:
- Instagram Insights
- Google Analytics
- Relatórios do WhatsApp Business
## Casos de Sucesso Inspiradores
### Padaria da Dona Maria
**Antes:** Apenas loja física, dependia de clientes que passavam na frente
**Depois:**
- BCard com cardápio e WhatsApp
- QR Code nas embalagens
- Pedidos pelo WhatsApp aumentaram 60%
- Clientes de bairros vizinhos começaram a pedir
### Consultório do Dr. Pedro (Dentista)
**Antes:** Agendamentos apenas por telefone em horário comercial
**Depois:**
- Sistema de agendamento online
- BCard com links para WhatsApp, Google Maps e Instagram
- Reduziu trabalho da secretária em 40%
- Mais agendamentos fora do horário comercial
### Loja de Roupas da Ana
**Antes:** Vendas apenas presenciais
**Depois:**
- Catálogo no Instagram
- BCard com link para catálogo completo
- WhatsApp Business para pedidos
- Vendas online representam 35% do faturamento
## Próximos Passos Para Seu Negócio
### Semana 1: Organização
- Liste todos os seus pontos de contato atuais
- Crie um BCard com todos os links
- Configure WhatsApp Business
### Semana 2: Presença Online
- Atualize perfis de redes sociais
- Adicione o BCard na bio
- Tire fotos profissionais dos produtos/serviços
### Semana 3: Divulgação
- Crie QR Code do seu BCard
- Coloque em materiais impressos
- Peça avaliações de clientes fiéis
### Semana 4: Otimização
- Analise o que funcionou
- Ajuste e melhore
- Planeje próximos passos
## Conclusão
A transformação digital para pequenos negócios não precisa ser cara ou complicada. Comece com o básico:
1. Presença online simples (BCard + redes sociais)
2. Facilite o contato (WhatsApp Business)
3. Aceite pagamentos digitais (Pix + cartão)
4. Peça avaliações de clientes
5. Evolua gradualmente
O importante é começar. Cada pequeno passo digital que você dá coloca seu negócio à frente dos concorrentes que ainda não se moveram.
**Pronto para dar o primeiro passo?** [Crie seu BCard profissional grátis](https://bcards.site) e comece sua transformação digital hoje!
---
## Recursos Adicionais
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
- [BCards para Advogados](/tutoriais/advocacia/como-advogados-podem-usar-bcards)
- [Fale com nosso suporte](/support)

View File

@ -0,0 +1,197 @@
---
title: "BCards para Advogados: Guia Completo"
description: "Descubra como advogados podem usar o BCards para fortalecer sua presença digital, atrair mais clientes e organizar todos os seus contatos profissionais"
keywords: "advogado, advocacia, cartão digital, marketing jurídico, presença online, bcards"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/tutoriais/advocacia/advogados-bcards-hero.jpg"
category: "advocacia"
---
# BCards para Advogados: Guia Completo
Na era digital, ter uma presença online profissional é essencial para advogados que querem se destacar e atrair mais clientes. O BCards é a solução perfeita para centralizar todos os seus contatos e informações profissionais em um único lugar.
## Por que Advogados Precisam de um BCard?
### 1. Credibilidade Profissional
Um cartão digital transmite modernidade e profissionalismo. Seus clientes verão que você está atualizado com as tecnologias atuais.
### 2. Facilidade de Contato
Centralize todas as formas de contato em um único link:
- WhatsApp para consultas rápidas
- Email profissional
- Telefone do escritório
- Endereço com localização no mapa
- Redes sociais profissionais (LinkedIn)
### 3. Marketing Jurídico Eficiente
Divulgue seus serviços de forma ética e profissional:
- Link para artigos jurídicos que você escreve
- Vídeos educativos no YouTube
- Depoimentos de clientes satisfeitos
- Áreas de atuação
## Como Configurar seu BCard Profissional
### Passo 1: Informações Essenciais
Inclua em seu perfil:
**Informações Básicas:**
- Nome completo e OAB
- Foto profissional (de terno/blazer)
- Bio concisa e profissional
- Áreas de especialização
**Exemplo de Bio:**
> "Dr. João Silva - OAB/SP 123.456
> Advogado especialista em Direito do Consumidor
> Atuação em todo território nacional
> Mais de 15 anos de experiência"
### Passo 2: Links Estratégicos
Organize seus links por ordem de importância:
1. **WhatsApp Business** - Para consultas rápidas
2. **Agendar Consulta** - Link para sistema de agendamento
3. **Áreas de Atuação** - Página explicando seus serviços
4. **Blog Jurídico** - Artigos e conteúdo educativo
5. **LinkedIn** - Networking profissional
6. **Localização** - Google Maps do seu escritório
### Passo 3: Conteúdo Educativo
Advogados podem se destacar compartilhando conhecimento:
- Artigos sobre direitos do consumidor
- Vídeos explicando processos jurídicos
- FAQ com perguntas frequentes
- Guias práticos para clientes
## Estratégias de Marketing para Advogados
### 1. Use no Cartão de Visitas
Adicione um QR Code do seu BCard no cartão de visitas físico. Assim, as pessoas podem salvar todos os seus contatos instantaneamente.
### 2. Assinatura de Email
Inclua o link do seu BCard na assinatura de todos os emails profissionais.
**Exemplo:**
```
Dr. João Silva
Advogado - OAB/SP 123.456
📱 Todos os meus contatos: bcards.site/page/advocacia/joao-silva
```
### 3. Redes Sociais
- Instagram: coloque na bio
- LinkedIn: adicione na seção "Sobre"
- Facebook: fixe uma publicação com o link
### 4. Materiais Impressos
Adicione QR Codes em:
- Folders informativos
- Cartazes do escritório
- Apresentações em palestras
- Documentos entregues a clientes
## Links Recomendados para Advogados
### Essenciais
- ✅ WhatsApp Business (atendimento)
- ✅ Email profissional
- ✅ Telefone do escritório
- ✅ Localização (Google Maps)
- ✅ LinkedIn
### Opcionais mas Recomendados
- 📝 Blog jurídico
- 📺 Canal no YouTube (vídeos educativos)
- 📅 Sistema de agendamento online
- 📄 Portfólio de casos (quando permitido)
- 💬 Depoimentos de clientes
- 📖 E-books e materiais gratuitos
## Ética e Boas Práticas
### O que Fazer ✅
- Mantenha uma apresentação sóbria e profissional
- Use foto com traje formal
- Divulgue conteúdo educativo
- Informe número da OAB sempre
- Seja transparente sobre áreas de atuação
### O que Evitar ❌
- Promessas de resultado garantido
- Comparações com outros advogados
- Preços divulgados publicamente (consulte a OAB)
- Linguagem sensacionalista
- Imagens inadequadas
## Casos de Uso Reais
### Caso 1: Dra. Maria - Direito de Família
A Dra. Maria aumentou suas consultas em 40% após criar seu BCard. Ela incluiu:
- Link para agendar consulta online
- Artigos sobre divórcio e pensão alimentícia
- Depoimentos de clientes (com autorização)
- WhatsApp exclusivo para novos casos
### Caso 2: Dr. Pedro - Direito Trabalhista
O Dr. Pedro usa seu BCard para:
- Compartilhar guias sobre direitos trabalhistas
- Vídeos curtos explicando rescisão, FGTS, etc.
- Formulário para pré-avaliação de casos
- Links para suas redes sociais educativas
## Planos Recomendados
### Para Advogados Iniciantes
**Plano Básico (R$ 12,90/mês)**
- Até 15 links
- Temas profissionais
- Analytics para acompanhar acessos
### Para Escritórios Estabelecidos
**Plano Premium (R$ 29,90/mês)**
- Links ilimitados
- Logo do escritório
- Cores personalizadas
- Suporte prioritário
- Upload de PDFs para contratos, petições e materiais exclusivos
## Conclusão
O BCards é uma ferramenta essencial para advogados modernos que querem:
- Facilitar o contato com clientes
- Apresentar-se de forma profissional
- Centralizar todas as informações em um só lugar
- Aumentar a captação de clientes
A melhor parte? É rápido de configurar e totalmente compatível com as regras da OAB para marketing jurídico.
**Pronto para modernizar sua presença digital?** [Crie seu BCard profissional agora!](https://bcards.site)
---
## Recursos Adicionais
- [Tutorial: Como Criar um BCard](/tutoriais/tecnologia/como-criar-um-bcard)
- [Suporte BCards](/support)
- [Políticas de Privacidade](/privacidade)

View File

@ -0,0 +1,148 @@
---
title: "Como Criar um BCard Profissional em 5 Minutos"
description: "Aprenda passo a passo como criar seu cartão digital profissional com BCards e centralize todos os seus links em um único lugar"
keywords: "bcards, cartão digital, tutorial, tecnologia, links, página profissional"
author: "BCards"
date: 2025-11-02
lastmod: 2025-11-02
image: "/images/tutoriais/tecnologia/criar-bcard-hero.jpg"
category: "tecnologia"
---
# Como Criar um BCard Profissional em 5 Minutos
Criar um cartão digital profissional nunca foi tão fácil! Neste tutorial, vamos mostrar como você pode ter sua página de links profissional em apenas 5 minutos.
## O que é um BCard?
O BCard é sua página profissional na internet, onde você centraliza todos os seus links importantes:
- Redes sociais (Instagram, LinkedIn, Facebook)
- WhatsApp para contato
- Site ou portfólio
- Links de produtos ou serviços
- E muito mais!
## Passo 1: Faça seu Cadastro
1. Acesse [bcards.site](https://bcards.site)
2. Clique em "Criar meu BCard grátis"
3. Escolha fazer login com Google ou Microsoft
4. Pronto! Sua conta está criada
> **Dica:** Use o mesmo email que você usa profissionalmente para facilitar o gerenciamento
## Passo 2: Escolha seu Tema
Temos diversos temas profissionais disponíveis:
- **Minimalista**: Clean e elegante
- **Moderno**: Cores vibrantes e design atual
- **Profissional**: Sóbrio e corporativo
- **Criativo**: Para quem quer se destacar
Escolha o tema que mais combina com sua personalidade ou marca!
## Passo 3: Adicione seus Links
Agora é hora de adicionar os links mais importantes:
1. Clique em "Adicionar Link"
2. Escolha o tipo (Instagram, WhatsApp, Site, etc.)
3. Cole o link
4. Dê um título descritivo
5. Salve!
### Tipos de Links Suportados
- Redes Sociais (Instagram, Facebook, LinkedIn, TikTok)
- Contato (WhatsApp, Email, Telefone)
- Sites e Portfólios
- Vídeos (YouTube, Vimeo)
- Documentos e PDFs
- Links personalizados
## Passo 4: Personalize seu Perfil
Deixe seu BCard com a sua cara:
- Adicione uma foto de perfil profissional
- Escreva uma bio atrativa
- Configure cores personalizadas (plano Premium)
- Adicione seu logo (plano Premium)
## Passo 5: Compartilhe!
Agora que está pronto, é hora de compartilhar:
- Seu link será: `bcards.site/page/{categoria}/{seu-nome}`
- Adicione na bio do Instagram
- Coloque na assinatura de email
- Compartilhe em grupos de WhatsApp
- Adicione ao cartão de visitas físico com QR Code
## Dicas Profissionais
### 1. Mantenha Atualizado
Revise seus links regularmente e remova os que não são mais relevantes.
### 2. Use Descrições Claras
Ao invés de "Clique aqui", use "Veja meu portfólio completo" ou "Agende uma consulta".
### 3. Priorize os Links Mais Importantes
Coloque os links mais relevantes no topo. As pessoas geralmente clicam nos primeiros links.
### 4. Teste Regularmente
Clique em todos os seus links periodicamente para garantir que estão funcionando.
## Planos e Recursos
### Plano Gratuito
- Até 5 links
- Temas básicos
- Analytics básico
### Plano Básico (R$ 12,90/mês)
- Até 15 links
- Todos os temas
- Analytics detalhado
### Plano Premium (R$ 29,90/mês)
- Links ilimitados
- Temas personalizados
- Logo personalizado
- Suporte prioritário
- Upload de PDFs para materiais extras
## Conclusão
Criar seu BCard profissional é rápido, fácil e pode transformar a forma como você se apresenta online. Em apenas 5 minutos, você tem uma página profissional que centraliza todos os seus contatos e links importantes.
**Pronto para começar?** [Crie seu BCard grátis agora!](https://bcards.site)
---
## Perguntas Frequentes
### Posso mudar meu tema depois?
Sim! Você pode mudar o tema quantas vezes quiser, a qualquer momento.
### Como faço para adicionar um QR Code?
No dashboard, clique em "Baixar QR Code" para fazer download do código QR do seu BCard.
### Posso ter mais de um BCard?
No momento, cada conta pode ter apenas um BCard ativo.
### Precisa de mais ajuda?
Entre em contato com nosso [suporte](https://bcards.site/support) ou confira outros tutoriais!

View File

@ -16,9 +16,9 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
- **Renderização SSR**: SEO-friendly - **Renderização SSR**: SEO-friendly
### 🎯 Planos e Pricing (Estratégia Decoy) ### 🎯 Planos e Pricing (Estratégia Decoy)
- **Básico** (R$ 9,90/mês): 5 links, temas básicos, analytics simples - **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples
- **Profissional** (R$ 24,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)* - **Profissional** (R$ 25,90/mês): 15 links, todos os temas, analytics avançado, domínio personalizado *(DECOY)*
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios - **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios, upload de PDFs
## 🛠️ Tecnologias ## 🛠️ Tecnologias
@ -94,8 +94,8 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
1. Crie uma conta no [Stripe](https://stripe.com) 1. Crie uma conta no [Stripe](https://stripe.com)
2. Configure os produtos e preços: 2. Configure os produtos e preços:
- Básico: R$ 9,90/mês - Básico: R$ 12,90/mês
- Profissional: R$ 24,90/mês - Profissional: R$ 25,90/mês
- Premium: R$ 29,90/mês - Premium: R$ 29,90/mês
3. Configure webhooks para: `/webhook/stripe` 3. Configure webhooks para: `/webhook/stripe`
4. Eventos necessários: 4. Eventos necessários:
@ -316,4 +316,4 @@ Para suporte técnico, entre em contato:
--- ---
**Desenvolvido com ❤️ para profissionais brasileiros e hispânicos** **Desenvolvido com ❤️ para profissionais brasileiros e hispânicos**

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,67 @@
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
using BCards.Web.Areas.Tutoriais.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Areas.Artigos.Controllers;
[Area("Artigos")]
public class ArtigosController : Controller
{
private readonly IMarkdownService _markdownService;
private readonly ILogger<ArtigosController> _logger;
public ArtigosController(
IMarkdownService markdownService,
ILogger<ArtigosController> logger)
{
_markdownService = markdownService;
_logger = logger;
}
// GET /artigos
public async Task<IActionResult> Index()
{
var artigos = await _markdownService
.GetAllArticlesAsync("Artigos", "pt-BR");
return View(artigos);
}
// GET /artigos/{slug}
public async Task<IActionResult> Article(string slug)
{
// Sanitização
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
try
{
var article = await _markdownService.GetArticleAsync(
$"Artigos/{slug}",
"pt-BR"
);
if (article == null)
{
_logger.LogWarning("Artigo não encontrado: {Slug}", slug);
return NotFound();
}
// Buscar outros artigos para "Leia também"
article.RelatedArticles = await _markdownService
.GetAllArticlesAsync("Artigos", "pt-BR");
article.RelatedArticles = article.RelatedArticles
.Where(a => a.Slug != slug)
.OrderByDescending(a => a.Date)
.Take(3)
.ToList();
return View(article);
}
catch (FileNotFoundException)
{
_logger.LogWarning("Arquivo markdown não encontrado: {Slug}", slug);
return NotFound();
}
}
}

View File

@ -0,0 +1,253 @@
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
@{
ViewData["Title"] = Model.Metadata.Title;
}
@section Head {
<!-- Meta Tags SEO -->
<meta name="description" content="@Model.Metadata.Description">
<meta name="keywords" content="@Model.Metadata.Keywords">
<meta name="author" content="@Model.Metadata.Author">
<meta name="robots" content="index, follow">
<link rel="canonical" href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = Model.Slug }, Context.Request.Scheme)">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="@Model.Metadata.Title">
<meta property="og:description" content="@Model.Metadata.Description">
<meta property="og:image" content="@Model.Metadata.Image">
<meta property="og:url" content="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = Model.Slug }, Context.Request.Scheme)">
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:author" content="@Model.Metadata.Author">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@Model.Metadata.Title">
<meta name="twitter:description" content="@Model.Metadata.Description">
<meta name="twitter:image" content="@Model.Metadata.Image">
<!-- Schema.org JSON-LD -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Article",
"headline": "@Model.Metadata.Title",
"description": "@Model.Metadata.Description",
"image": "@Model.Metadata.Image",
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
"author": {
"@@type": "Person",
"name": "@Model.Metadata.Author"
},
"publisher": {
"@@type": "Organization",
"name": "BCards",
"logo": {
"@@type": "ImageObject",
"url": "https://bcards.site/logo.png"
}
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{
"@@type": "ListItem",
"position": 1,
"name": "Início",
"item": "https://bcards.site"
},
{
"@@type": "ListItem",
"position": 2,
"name": "Artigos",
"item": "https://bcards.site/artigos"
},
{
"@@type": "ListItem",
"position": 3,
"name": "@Model.Metadata.Title"
}
]
}
</script>
}
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Início</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Artigos", new { area = "Artigos" })">Artigos</a></li>
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
</ol>
</nav>
<!-- Article Header -->
<article>
<header class="mb-4">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-success me-2">✨ Artigo</span>
<span class="text-muted small"><i class="far fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes min de leitura</span>
</div>
<h1 class="display-5 mb-3">@Model.Metadata.Title</h1>
<p class="lead text-muted">@Model.Metadata.Description</p>
<div class="d-flex align-items-center text-muted small mb-3">
<span class="me-3"><i class="fas fa-user me-1"></i> @Model.Metadata.Author</span>
<span class="me-3"><i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")</span>
<span><i class="fas fa-sync me-1"></i> Atualizado em @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</span>
</div>
</header>
@if (!string.IsNullOrEmpty(Model.Metadata.Image))
{
<img src="@Model.Metadata.Image" class="img-fluid rounded mb-4" alt="@Model.Metadata.Title">
}
<!-- Article Content -->
<div class="article-content">
@Html.Raw(Model.HtmlContent)
</div>
</article>
<!-- CTA -->
<div class="alert alert-primary mt-5" role="alert">
<h4 class="alert-heading"><i class="fas fa-rocket me-2"></i> Pronto para transformar seu negócio?</h4>
<p class="mb-3">Crie seu cartão digital profissional e comece a atrair mais clientes hoje mesmo!</p>
<a href="/" class="btn btn-primary">Criar meu BCard grátis</a>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="sticky-top" style="top: 20px;">
<!-- Related Articles -->
@if (Model.RelatedArticles.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="fas fa-newspaper me-2"></i> Leia Também</h5>
<div class="list-group list-group-flush">
@foreach (var related in Model.RelatedArticles)
{
<a href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = related.Slug })" class="list-group-item list-group-item-action border-0 px-0">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">@related.Title</h6>
</div>
<small class="text-muted"><i class="far fa-clock me-1"></i> @related.ReadingTimeMinutes min</small>
</a>
}
</div>
</div>
</div>
}
<!-- Tutoriais CTA -->
<div class="card border-0 shadow-sm bg-light mb-4">
<div class="card-body text-center">
<i class="fas fa-book-open fa-3x text-primary mb-3"></i>
<h5 class="card-title">Quer aprender mais?</h5>
<p class="card-text small text-muted">Acesse nossos tutoriais práticos</p>
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-sm btn-primary">Ver Tutoriais</a>
</div>
</div>
<!-- Help Card -->
<div class="card border-0 shadow-sm bg-light">
<div class="card-body text-center">
<i class="fas fa-question-circle fa-3x text-primary mb-3"></i>
<h5 class="card-title">Precisa de ajuda?</h5>
<p class="card-text small text-muted">Entre em contato com nosso suporte</p>
<a href="/Support" class="btn btn-sm btn-outline-primary">Falar com suporte</a>
</div>
</div>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.article-content {
font-size: 1.1rem;
line-height: 1.8;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.article-content h2 {
font-size: 1.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #dee2e6;
}
.article-content h3 {
font-size: 1.5rem;
}
.article-content p {
margin-bottom: 1.5rem;
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 1.5rem 0;
}
.article-content ul,
.article-content ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.article-content li {
margin-bottom: 0.5rem;
}
.article-content code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
.article-content pre {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.article-content blockquote {
border-left: 4px solid #198754;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
color: #6c757d;
}
.article-content table {
width: 100%;
margin-bottom: 1.5rem;
border-collapse: collapse;
}
.article-content table th,
.article-content table td {
padding: 0.75rem;
border: 1px solid #dee2e6;
}
.article-content table th {
background-color: #f8f9fa;
font-weight: 600;
}
</style>
}

View File

@ -0,0 +1,77 @@
@model List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>
@{
ViewData["Title"] = "Artigos BCards - Inspiração e Conhecimento";
}
<div class="container py-5">
<div class="row mb-5">
<div class="col-lg-8 mx-auto text-center">
<h1 class="display-4 mb-3">✨ Artigos BCards</h1>
<p class="lead text-muted">Insights, tendências e inspiração para transformar sua presença digital</p>
</div>
</div>
@if (Model.Any())
{
<div class="row g-4">
@foreach (var artigo in Model)
{
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm hover-shadow">
@if (!string.IsNullOrEmpty(artigo.Image))
{
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
}
<div class="card-body">
<h5 class="card-title">@artigo.Title</h5>
<p class="card-text text-muted small">@artigo.Description</p>
<div class="d-flex justify-content-between align-items-center text-muted small">
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="@Url.Action("Article", "Artigos", new { area = "Artigos", slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
Ler artigo <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-5">
<i class="fas fa-newspaper fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Nenhum artigo disponível ainda</h3>
<p class="text-muted">Em breve teremos conteúdo inspirador para você!</p>
</div>
}
<!-- CTA -->
<div class="row mt-5">
<div class="col-lg-8 mx-auto">
<div class="card bg-primary text-white border-0 shadow">
<div class="card-body text-center p-5">
<h3 class="mb-3">Quer ver tutoriais práticos?</h3>
<p class="mb-4">Acesse nossa seção de tutoriais e aprenda passo a passo como usar o BCards</p>
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-light btn-lg">
<i class="fas fa-book-open me-2"></i> Ver Tutoriais
</a>
</div>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.hover-shadow {
transition: box-shadow 0.3s ease-in-out;
}
.hover-shadow:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
}

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -0,0 +1,86 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Route("api/ratings")]
[ApiController]
public class RatingsController : ControllerBase
{
private readonly IRatingService _ratingService;
private readonly ILogger<RatingsController> _logger;
public RatingsController(IRatingService ratingService, ILogger<RatingsController> logger)
{
_ratingService = ratingService;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> SubmitRating([FromBody] RatingSubmissionDto dto)
{
if (!ModelState.IsValid)
{
_logger.LogWarning("Rating inválido submetido: {Errors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return BadRequest(ModelState);
}
try
{
var userId = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var success = await _ratingService.SubmitRatingAsync(dto, userId, HttpContext);
if (success)
{
_logger.LogInformation("Rating de {Stars} estrelas submetido com sucesso", dto.RatingValue);
return Ok(new { message = "Avaliação enviada com sucesso! Obrigado pelo feedback." });
}
_logger.LogError("Falha ao submeter rating");
return StatusCode(503, new { message = "Erro ao processar sua avaliação. Tente novamente mais tarde." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar rating");
return StatusCode(500, new { message = "Erro interno ao processar sua avaliação." });
}
}
[HttpGet("average")]
public async Task<IActionResult> GetAverageRating()
{
try
{
var average = await _ratingService.GetAverageRatingAsync();
var total = await _ratingService.GetTotalCountAsync();
return Ok(new { average = Math.Round(average, 2), total });
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar média de ratings");
return StatusCode(500, new { message = "Erro ao buscar avaliações" });
}
}
[HttpGet("recent")]
public async Task<IActionResult> GetRecentRatings([FromQuery] int limit = 10)
{
try
{
if (limit < 1 || limit > 50)
limit = 10;
var ratings = await _ratingService.GetRecentRatingsAsync(limit);
return Ok(ratings);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings recentes");
return StatusCode(500, new { message = "Erro ao buscar avaliações recentes" });
}
}
}

View File

@ -0,0 +1,46 @@
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.Controllers;
[Area("Support")]
[Authorize]
public class SupportController : Controller
{
private readonly ISupportService _supportService;
private readonly ILogger<SupportController> _logger;
public SupportController(ISupportService supportService, ILogger<SupportController> logger)
{
_supportService = supportService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> ContactForm()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
if (!options.CanUseContactForm)
{
_logger.LogWarning("Usuário {UserId} tentou acessar formulário sem permissão", userId);
TempData["Error"] = "Seu plano atual não tem acesso ao formulário de contato. Faça upgrade para o plano Básico ou superior.";
return RedirectToAction("Index", "Home", new { area = "" });
}
return View(options);
}
[HttpGet]
[Route("Support/Index")]
public async Task<IActionResult> Index()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
return View(options);
}
}

View File

@ -0,0 +1,24 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Areas.Support.Models;
public class Rating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
public int RatingValue { get; set; } // 1-5 stars
public string? Name { get; set; }
public string? Email { get; set; }
public string? Comment { get; set; }
public string? UserId { get; set; } // null para anônimos
public string? IpAddress { get; set; }
public string? UserAgent { get; set; }
public string? Culture { get; set; }
public string? Url { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace BCards.Web.Areas.Support.Models;
public class RatingSubmissionDto
{
[Required]
[Range(1, 5, ErrorMessage = "A avaliação deve ser entre 1 e 5 estrelas")]
public int RatingValue { get; set; }
[StringLength(100, ErrorMessage = "O nome deve ter no máximo 100 caracteres")]
public string? Name { get; set; }
[EmailAddress(ErrorMessage = "Email inválido")]
public string? Email { get; set; }
[StringLength(500, ErrorMessage = "O comentário deve ter no máximo 500 caracteres")]
public string? Comment { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace BCards.Web.Areas.Support.Models;
public class SupportOptions
{
public bool CanRate { get; set; }
public bool CanUseContactForm { get; set; }
public bool CanAccessTelegram { get; set; }
public string? TelegramUrl { get; set; }
public string? FormspreeUrl { get; set; }
public string UserPlan { get; set; } = "Trial";
}

View File

@ -0,0 +1,12 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Repositories;
public interface IRatingRepository
{
Task<Rating> CreateAsync(Rating rating);
Task<List<Rating>> GetRecentAsync(int limit = 10);
Task<double> GetAverageRatingAsync();
Task<int> GetTotalCountAsync();
Task<List<Rating>> GetByUserIdAsync(string userId);
}

View File

@ -0,0 +1,123 @@
using BCards.Web.Areas.Support.Models;
using MongoDB.Driver;
namespace BCards.Web.Areas.Support.Repositories;
public class RatingRepository : IRatingRepository
{
private readonly IMongoCollection<Rating> _ratings;
private readonly ILogger<RatingRepository> _logger;
public RatingRepository(IMongoDatabase database, ILogger<RatingRepository> logger)
{
_ratings = database.GetCollection<Rating>("ratings");
_logger = logger;
// Criar índices
CreateIndexes();
}
private void CreateIndexes()
{
try
{
var indexKeysDefinition = Builders<Rating>.IndexKeys.Descending(r => r.CreatedAt);
var indexModel = new CreateIndexModel<Rating>(indexKeysDefinition);
_ratings.Indexes.CreateOne(indexModel);
var userIdIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.UserId);
var userIdIndexModel = new CreateIndexModel<Rating>(userIdIndexKeys);
_ratings.Indexes.CreateOne(userIdIndexModel);
var ratingValueIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.RatingValue);
var ratingValueIndexModel = new CreateIndexModel<Rating>(ratingValueIndexKeys);
_ratings.Indexes.CreateOne(ratingValueIndexModel);
var cultureIndexKeys = Builders<Rating>.IndexKeys.Ascending(r => r.Culture);
var cultureIndexModel = new CreateIndexModel<Rating>(cultureIndexKeys);
_ratings.Indexes.CreateOne(cultureIndexModel);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Não foi possível criar índices para a collection ratings");
}
}
public async Task<Rating> CreateAsync(Rating rating)
{
try
{
await _ratings.InsertOneAsync(rating);
_logger.LogInformation("Rating criado com sucesso: {RatingId}", rating.Id);
return rating;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao criar rating");
throw;
}
}
public async Task<List<Rating>> GetRecentAsync(int limit = 10)
{
try
{
return await _ratings
.Find(_ => true)
.SortByDescending(r => r.CreatedAt)
.Limit(limit)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings recentes");
return new List<Rating>();
}
}
public async Task<double> GetAverageRatingAsync()
{
try
{
var ratings = await _ratings.Find(_ => true).ToListAsync();
if (ratings.Count == 0)
return 0;
return ratings.Average(r => r.RatingValue);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao calcular média de ratings");
return 0;
}
}
public async Task<int> GetTotalCountAsync()
{
try
{
return (int)await _ratings.CountDocumentsAsync(_ => true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao contar ratings");
return 0;
}
}
public async Task<List<Rating>> GetByUserIdAsync(string userId)
{
try
{
return await _ratings
.Find(r => r.UserId == userId)
.SortByDescending(r => r.CreatedAt)
.ToListAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao buscar ratings do usuário {UserId}", userId);
return new List<Rating>();
}
}
}

View File

@ -0,0 +1,11 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Services;
public interface IRatingService
{
Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext);
Task<double> GetAverageRatingAsync();
Task<int> GetTotalCountAsync();
Task<List<Rating>> GetRecentRatingsAsync(int limit = 10);
}

View File

@ -0,0 +1,8 @@
using BCards.Web.Areas.Support.Models;
namespace BCards.Web.Areas.Support.Services;
public interface ISupportService
{
Task<SupportOptions> GetAvailableOptionsAsync(string? userId);
}

View File

@ -0,0 +1,61 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Areas.Support.Repositories;
using System.Globalization;
namespace BCards.Web.Areas.Support.Services;
public class RatingService : IRatingService
{
private readonly IRatingRepository _repository;
private readonly ILogger<RatingService> _logger;
public RatingService(IRatingRepository repository, ILogger<RatingService> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<bool> SubmitRatingAsync(RatingSubmissionDto dto, string? userId, HttpContext httpContext)
{
try
{
var rating = new Rating
{
RatingValue = dto.RatingValue,
Name = dto.Name,
Email = dto.Email,
Comment = dto.Comment,
UserId = userId,
IpAddress = httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = httpContext.Request.Headers["User-Agent"].ToString(),
Culture = CultureInfo.CurrentCulture.Name,
Url = httpContext.Request.Headers["Referer"].ToString(),
CreatedAt = DateTime.UtcNow
};
await _repository.CreateAsync(rating);
_logger.LogInformation("Rating submetido com sucesso por usuário {UserId}", userId ?? "anônimo");
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao submeter rating");
return false;
}
}
public async Task<double> GetAverageRatingAsync()
{
return await _repository.GetAverageRatingAsync();
}
public async Task<int> GetTotalCountAsync()
{
return await _repository.GetTotalCountAsync();
}
public async Task<List<Rating>> GetRecentRatingsAsync(int limit = 10)
{
return await _repository.GetRecentAsync(limit);
}
}

View File

@ -0,0 +1,101 @@
using BCards.Web.Areas.Support.Models;
using BCards.Web.Configuration;
using BCards.Web.Models;
using BCards.Web.Repositories;
using Microsoft.Extensions.Options;
namespace BCards.Web.Areas.Support.Services;
public class SupportService : ISupportService
{
private readonly IUserRepository _userRepository;
private readonly IOptions<SupportSettings> _settings;
private readonly ILogger<SupportService> _logger;
public SupportService(
IUserRepository userRepository,
IOptions<SupportSettings> settings,
ILogger<SupportService> logger)
{
_userRepository = userRepository;
_settings = settings;
_logger = logger;
}
public async Task<SupportOptions> GetAvailableOptionsAsync(string? userId)
{
var options = new SupportOptions
{
CanRate = _settings.Value.EnableRatingForAllUsers,
CanUseContactForm = false,
CanAccessTelegram = false,
TelegramUrl = _settings.Value.TelegramUrl,
FormspreeUrl = _settings.Value.FormspreeUrl,
UserPlan = "Trial"
};
// Usuário não autenticado ou trial
if (string.IsNullOrEmpty(userId))
{
_logger.LogDebug("Usuário não autenticado - apenas rating disponível");
return options;
}
try
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null)
{
_logger.LogWarning("Usuário {UserId} não encontrado", userId);
return options;
}
var planName = user.CurrentPlan?.ToLower() ?? "trial";
options.UserPlan = planName;
_logger.LogDebug("Verificando opções de suporte para usuário {UserId} com plano {Plan}", userId, planName);
// Trial: apenas rating
if (planName == "trial")
{
_logger.LogDebug("Plano Trial - apenas rating disponível");
return options;
}
// Básico: rating + formulário
if (planName == "basic" || planName == "básico")
{
options.CanUseContactForm = true;
options.UserPlan = "Básico";
_logger.LogDebug("Plano Básico - rating + formulário disponíveis");
return options;
}
// Profissional: rating + formulário (sem telegram)
if (planName == "professional" || planName == "profissional")
{
options.CanUseContactForm = true;
options.UserPlan = "Profissional";
_logger.LogDebug("Plano Profissional - rating + formulário disponíveis");
return options;
}
// Premium e PremiumAffiliate: tudo
if (planName == "premium" || planName == "premiumaffiliate" || planName == "premium+afiliados")
{
options.CanUseContactForm = true;
options.CanAccessTelegram = true;
options.UserPlan = planName.Contains("affiliate") || planName.Contains("afiliados") ? "Premium+Afiliados" : "Premium";
_logger.LogDebug("Plano {Plan} - todas as opções disponíveis", planName);
return options;
}
return options;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao verificar opções de suporte para usuário {UserId}", userId);
return options;
}
}
}

View File

@ -0,0 +1,36 @@
using BCards.Web.Areas.Support.Services;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace BCards.Web.Areas.Support.ViewComponents;
public class SupportFabViewComponent : ViewComponent
{
private readonly ISupportService _supportService;
private readonly ILogger<SupportFabViewComponent> _logger;
public SupportFabViewComponent(ISupportService supportService, ILogger<SupportFabViewComponent> logger)
{
_supportService = supportService;
_logger = logger;
}
public async Task<IViewComponentResult> InvokeAsync()
{
try
{
var userId = UserClaimsPrincipal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var options = await _supportService.GetAvailableOptionsAsync(userId);
_logger.LogDebug("SupportFab invocado para usuário {UserId} - Opções: Rating={CanRate}, Form={CanUseContactForm}, Telegram={CanAccessTelegram}",
userId ?? "anônimo", options.CanRate, options.CanUseContactForm, options.CanAccessTelegram);
return View(options);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao carregar SupportFab ViewComponent");
return Content(string.Empty);
}
}
}

View File

@ -0,0 +1,134 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
<!-- Support FAB (Floating Action Button) -->
<div class="support-fab-container">
<button class="support-fab-trigger" id="supportFabTrigger" aria-label="Precisa de Ajuda?">
<i class="fas fa-question-circle"></i>
</button>
<div class="support-fab-menu" id="supportFabMenu" style="display: none;">
<div class="support-fab-header">
<h6>Precisa de Ajuda?</h6>
<p class="small text-muted mb-0">Plano: @Model.UserPlan</p>
</div>
@if (Model.CanAccessTelegram)
{
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="support-fab-option support-fab-telegram">
<div class="support-fab-option-icon">
<i class="fab fa-telegram"></i>
</div>
<div class="support-fab-option-content">
<strong>Falar no Telegram</strong>
<small>Suporte prioritário</small>
</div>
</a>
}
@if (Model.CanUseContactForm)
{
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="support-fab-option support-fab-form">
<div class="support-fab-option-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="support-fab-option-content">
<strong>Enviar Mensagem</strong>
<small>Formulário de contato</small>
</div>
</a>
}
@if (Model.CanRate)
{
<button type="button" class="support-fab-option support-fab-rating" data-bs-toggle="modal" data-bs-target="#ratingModal">
<div class="support-fab-option-icon">
<i class="fas fa-star"></i>
</div>
<div class="support-fab-option-content">
<strong>Avaliar Serviço</strong>
<small>Conte sua experiência</small>
</div>
</button>
}
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="support-fab-upgrade">
<p class="small mb-2">
<i class="fas fa-lock text-warning"></i>
Faça upgrade para acessar mais opções de suporte!
</p>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-sm btn-primary">Ver Planos</a>
</div>
}
</div>
</div>
<!-- Rating Modal -->
<div class="modal fade" id="ratingModal" tabindex="-1" aria-labelledby="ratingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ratingModalLabel">Avalie nossa plataforma</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<form id="ratingForm">
<div class="mb-3">
<label class="form-label">Sua avaliação: <span class="text-danger">*</span></label>
<div class="star-rating" id="starRating">
<i class="far fa-star" data-rating="1"></i>
<i class="far fa-star" data-rating="2"></i>
<i class="far fa-star" data-rating="3"></i>
<i class="far fa-star" data-rating="4"></i>
<i class="far fa-star" data-rating="5"></i>
</div>
<input type="hidden" id="ratingValue" name="ratingValue" required>
<div class="invalid-feedback d-block" id="ratingError" style="display: none !important;">
Por favor, selecione uma avaliação
</div>
</div>
<div class="mb-3">
<label for="ratingName" class="form-label">Nome (opcional)</label>
<input type="text" class="form-control" id="ratingName" name="name" maxlength="100" placeholder="Seu nome">
</div>
<div class="mb-3">
<label for="ratingEmail" class="form-label">Email (opcional)</label>
<input type="email" class="form-control" id="ratingEmail" name="email" placeholder="seu@email.com">
</div>
<div class="mb-3">
<label for="ratingComment" class="form-label">Comentário (opcional)</label>
<textarea class="form-control" id="ratingComment" name="comment" rows="3" maxlength="500" placeholder="Conte-nos sobre sua experiência..."></textarea>
<small class="form-text text-muted">
<span id="commentCounter">0</span>/500 caracteres
</small>
</div>
<div class="alert alert-success d-none" id="ratingSuccessAlert" role="alert">
<i class="fas fa-check-circle"></i> Avaliação enviada com sucesso! Obrigado pelo feedback.
</div>
<div class="alert alert-danger d-none" id="ratingErrorAlert" role="alert">
<i class="fas fa-exclamation-triangle"></i> <span id="ratingErrorMessage">Erro ao enviar avaliação. Tente novamente.</span>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary" id="submitRatingBtn">
<i class="fas fa-paper-plane"></i> Enviar Avaliação
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<link rel="stylesheet" href="~/css/support-fab.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/rating.css" asp-append-version="true" />
<script src="~/js/support-fab.js" asp-append-version="true"></script>
<script src="~/js/rating.js" asp-append-version="true"></script>
}

View File

@ -0,0 +1,155 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
@{
ViewData["Title"] = "Formulário de Contato";
Layout = "_Layout";
}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-body p-4">
<h2 class="card-title mb-4">
<i class="fas fa-envelope text-primary"></i> Fale Conosco
</h2>
<div class="alert alert-info mb-4">
<i class="fas fa-info-circle"></i>
<strong>Tempo de resposta:</strong> Normalmente respondemos em até 24-48 horas.
</div>
<form action="@Model.FormspreeUrl" method="POST">
<div class="mb-3">
<label for="name" class="form-label">
Nome <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="name" name="name" required maxlength="100">
</div>
<div class="mb-3">
<label for="email" class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email" class="form-control" id="email" name="email" required>
<small class="form-text text-muted">Usaremos este email para responder sua mensagem.</small>
</div>
<div class="mb-3">
<label for="subject" class="form-label">
Assunto <span class="text-danger">*</span>
</label>
<select class="form-select" id="subject" name="subject" required>
<option value="">Selecione um assunto</option>
<option value="Suporte Técnico">Suporte Técnico</option>
<option value="Dúvida sobre Planos">Dúvida sobre Planos</option>
<option value="Problema de Pagamento">Problema de Pagamento</option>
<option value="Sugestão de Melhoria">Sugestão de Melhoria</option>
<option value="Reportar Bug">Reportar Bug</option>
<option value="Outro">Outro</option>
</select>
</div>
@if (Model.CanAccessTelegram)
{
<div class="mb-3">
<label for="preferredContact" class="form-label">
Canal de contato preferido
</label>
<select class="form-select" id="preferredContact" name="preferredContact">
<option value="email" selected>Email</option>
<option value="telegram">Telegram</option>
</select>
</div>
}
else
{
<input type="hidden" name="preferredContact" value="email">
}
<div class="mb-3">
<label for="message" class="form-label">
Mensagem <span class="text-danger">*</span>
</label>
<textarea class="form-control" id="message" name="message" rows="6" required maxlength="2000" placeholder="Descreva sua dúvida ou problema em detalhes..."></textarea>
<small class="form-text text-muted">
<span id="messageCounter">0</span>/2000 caracteres
</small>
</div>
<input type="hidden" name="_language" value="pt-BR">
<input type="hidden" name="_subject" value="Novo contato BCards">
<input type="hidden" name="userPlan" value="@Model.UserPlan">
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-paper-plane"></i> Enviar Mensagem
</button>
</div>
</form>
@if (Model.CanAccessTelegram)
{
<hr class="my-4">
<div class="text-center">
<p class="text-muted mb-3">Ou se preferir, entre em contato direto via Telegram:</p>
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary">
<i class="fab fa-telegram"></i> Abrir Telegram
</a>
</div>
}
</div>
</div>
<div class="mt-4">
<h4 class="mb-3">Perguntas Frequentes</h4>
<div class="accordion" id="faqAccordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq1">
Como faço upgrade do meu plano?
</button>
</h2>
<div id="faq1" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Você pode fazer upgrade a qualquer momento através da página de <a href="@Url.Action("Index", "Payment", new { area = "" })">Planos e Preços</a>.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq2">
Posso cancelar minha assinatura a qualquer momento?
</button>
</h2>
<div id="faq2" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Sim! Você pode cancelar sua assinatura a qualquer momento sem multas ou taxas adicionais.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#faq3">
Qual o tempo de resposta do suporte?
</button>
</h2>
<div id="faq3" class="accordion-collapse collapse" data-bs-parent="#faqAccordion">
<div class="accordion-body">
Respondemos em até 24-48h para planos Básico. Usuários Premium têm suporte prioritário com resposta em até 12h.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Character counter for message
document.getElementById('message').addEventListener('input', function() {
document.getElementById('messageCounter').textContent = this.value.length;
});
</script>
}

View File

@ -0,0 +1,90 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
@{
ViewData["Title"] = "Central de Suporte";
Layout = "_Layout";
}
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-lg-10">
<h1 class="mb-4">
<i class="fas fa-headset text-primary"></i> Central de Suporte
</h1>
<div class="alert alert-info">
<strong>Plano Atual:</strong> @Model.UserPlan
</div>
<div class="row g-4 mt-3">
@if (Model.CanAccessTelegram)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-primary mb-3">
<i class="fab fa-telegram"></i>
</div>
<h5 class="card-title">Telegram</h5>
<p class="card-text">Fale conosco diretamente pelo Telegram para suporte prioritário.</p>
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Abrir Telegram
</a>
</div>
</div>
</div>
}
@if (Model.CanUseContactForm)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-success mb-3">
<i class="fas fa-envelope"></i>
</div>
<h5 class="card-title">Formulário de Contato</h5>
<p class="card-text">Envie sua dúvida ou problema através do nosso formulário.</p>
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="btn btn-success">
Enviar Mensagem
</a>
</div>
</div>
</div>
}
@if (Model.CanRate)
{
<div class="col-md-4">
<div class="card h-100 text-center">
<div class="card-body">
<div class="display-4 text-warning mb-3">
<i class="fas fa-star"></i>
</div>
<h5 class="card-title">Avalie-nos</h5>
<p class="card-text">Conte-nos sobre sua experiência com a plataforma.</p>
<button type="button" class="btn btn-warning" data-bs-toggle="modal" data-bs-target="#ratingModal">
Avaliar Agora
</button>
</div>
</div>
</div>
}
</div>
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="alert alert-warning mt-4">
<h5><i class="fas fa-lock"></i> Desbloqueie Mais Recursos de Suporte!</h5>
<p class="mb-2">Seu plano atual tem acesso limitado. Faça upgrade para:</p>
<ul>
<li><strong>Plano Básico:</strong> Formulário de contato + Avaliações</li>
<li><strong>Plano Premium:</strong> Telegram + Formulário + Suporte Prioritário</li>
</ul>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-primary mt-2">
Ver Planos e Preços
</a>
</div>
}
</div>
</div>
</div>

View File

@ -0,0 +1,111 @@
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Services;
using BCards.Web.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Areas.Tutoriais.Controllers;
[Area("Tutoriais")]
public class TutoriaisController : Controller
{
private readonly IMarkdownService _markdownService;
private readonly ICategoryRepository _categoryRepository;
private readonly ILogger<TutoriaisController> _logger;
public TutoriaisController(
IMarkdownService markdownService,
ICategoryRepository categoryRepository,
ILogger<TutoriaisController> logger)
{
_markdownService = markdownService;
_categoryRepository = categoryRepository;
_logger = logger;
}
// GET /tutoriais
public async Task<IActionResult> Index()
{
var categories = await _categoryRepository.GetAllActiveAsync();
var tutoriaisPorCategoria = new Dictionary<string, List<ArticleMetadata>>();
foreach (var category in categories)
{
var artigos = await _markdownService
.GetArticlesByCategoryAsync(category.Slug, "pt-BR");
if (artigos.Any())
{
tutoriaisPorCategoria[category.Slug] = artigos;
}
}
ViewBag.Categories = categories;
return View(tutoriaisPorCategoria);
}
// GET /tutoriais/{categoria}
public async Task<IActionResult> Category(string categoria)
{
// Validar categoria existe
var category = await _categoryRepository.GetBySlugAsync(categoria);
if (category == null)
{
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
return NotFound();
}
var artigos = await _markdownService
.GetArticlesByCategoryAsync(categoria, "pt-BR");
ViewBag.Category = category;
return View(artigos);
}
// GET /tutoriais/{categoria}/{slug}
public async Task<IActionResult> Article(string categoria, string slug)
{
// Sanitização (segurança contra path traversal)
categoria = categoria.Replace("..", "").Replace("/", "").Replace("\\", "");
slug = slug.Replace("..", "").Replace("/", "").Replace("\\", "");
// Validar categoria existe
var category = await _categoryRepository.GetBySlugAsync(categoria);
if (category == null)
{
_logger.LogWarning("Categoria não encontrada: {Categoria}", categoria);
return NotFound();
}
try
{
var article = await _markdownService.GetArticleAsync(
$"Tutoriais/{categoria}/{slug}",
"pt-BR"
);
if (article == null)
{
_logger.LogWarning("Artigo não encontrado: {Categoria}/{Slug}", categoria, slug);
return NotFound();
}
// Buscar artigos relacionados da mesma categoria
article.RelatedArticles = await _markdownService
.GetArticlesByCategoryAsync(categoria, "pt-BR");
// Remover o artigo atual dos relacionados
article.RelatedArticles = article.RelatedArticles
.Where(a => a.Slug != slug)
.Take(3)
.ToList();
ViewBag.Category = category;
return View(article);
}
catch (FileNotFoundException)
{
_logger.LogWarning("Arquivo markdown não encontrado: {Categoria}/{Slug}", categoria, slug);
return NotFound();
}
}
}

View File

@ -0,0 +1,16 @@
namespace BCards.Web.Areas.Tutoriais.Models;
public class ArticleMetadata
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Keywords { get; set; } = string.Empty;
public string Author { get; set; } = "BCards";
public DateTime Date { get; set; }
public DateTime LastMod { get; set; }
public string Image { get; set; } = string.Empty;
public string Culture { get; set; } = "pt-BR";
public string? Category { get; set; } // Apenas para tutoriais
public int ReadingTimeMinutes { get; set; }
public string Slug { get; set; } = string.Empty;
}

View File

@ -0,0 +1,10 @@
namespace BCards.Web.Areas.Tutoriais.Models.ViewModels;
public class ArticleViewModel
{
public ArticleMetadata Metadata { get; set; } = new();
public string HtmlContent { get; set; } = string.Empty;
public string Slug { get; set; } = string.Empty;
public DateTime LastModified { get; set; }
public List<ArticleMetadata> RelatedArticles { get; set; } = new();
}

View File

@ -0,0 +1,11 @@
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
namespace BCards.Web.Areas.Tutoriais.Services;
public interface IMarkdownService
{
Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture);
Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture);
Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture);
}

View File

@ -0,0 +1,240 @@
using BCards.Web.Areas.Tutoriais.Models;
using BCards.Web.Areas.Tutoriais.Models.ViewModels;
using Markdig;
using Microsoft.Extensions.Caching.Memory;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace BCards.Web.Areas.Tutoriais.Services;
public class MarkdownService : IMarkdownService
{
private readonly IMemoryCache _cache;
private readonly ILogger<MarkdownService> _logger;
private readonly string _contentBasePath;
private readonly MarkdownPipeline _markdownPipeline;
private readonly IDeserializer _yamlDeserializer;
public MarkdownService(
IMemoryCache cache,
ILogger<MarkdownService> logger,
IWebHostEnvironment environment)
{
_cache = cache;
_logger = logger;
_contentBasePath = Path.Combine(environment.ContentRootPath, "Content");
// Pipeline Markdig com extensões avançadas
_markdownPipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions() // Tables, footnotes, etc.
.UseAutoLinks() // Auto-link URLs
.UseEmphasisExtras() // ~~strikethrough~~
.UseGenericAttributes() // {#id .class}
.DisableHtml() // Segurança: bloqueia HTML inline
.Build();
// Deserializador YAML
_yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
}
public async Task<ArticleViewModel?> GetArticleAsync(string relativePath, string culture)
{
var cacheKey = $"Article_{relativePath}_{culture}";
// Verificar cache
if (_cache.TryGetValue(cacheKey, out ArticleViewModel? cachedArticle))
{
_logger.LogDebug("Artigo encontrado no cache: {Path}", relativePath);
return cachedArticle;
}
// Construir caminho completo
var fullPath = Path.Combine(_contentBasePath, $"{relativePath}.{culture}.md");
if (!File.Exists(fullPath))
{
_logger.LogWarning("Arquivo não encontrado: {Path}", fullPath);
return null;
}
try
{
var content = await File.ReadAllTextAsync(fullPath);
var (metadata, markdownContent) = ExtractFrontmatter(content);
if (metadata == null)
{
_logger.LogError("Frontmatter inválido em: {Path}", fullPath);
return null;
}
// Processar markdown → HTML
var htmlContent = Markdown.ToHtml(markdownContent, _markdownPipeline);
// Calcular tempo de leitura (200 palavras/minuto)
var wordCount = markdownContent.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
metadata.ReadingTimeMinutes = Math.Max(1, wordCount / 200);
// Extrair slug do path
metadata.Slug = Path.GetFileNameWithoutExtension(
Path.GetFileNameWithoutExtension(relativePath.Split('/').Last())
);
metadata.Culture = culture;
var article = new ArticleViewModel
{
Metadata = metadata,
HtmlContent = htmlContent,
Slug = metadata.Slug,
LastModified = File.GetLastWriteTimeUtc(fullPath)
};
// Cache por 1 hora
_cache.Set(cacheKey, article, TimeSpan.FromHours(1));
_logger.LogInformation("Artigo processado e cacheado: {Path}", relativePath);
return article;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar artigo: {Path}", fullPath);
return null;
}
}
public async Task<List<ArticleMetadata>> GetArticlesByCategoryAsync(string category, string culture)
{
var cacheKey = $"CategoryArticles_{category}_{culture}";
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
{
return cached ?? new List<ArticleMetadata>();
}
var categoryPath = Path.Combine(_contentBasePath, "Tutoriais", category);
if (!Directory.Exists(categoryPath))
{
_logger.LogWarning("Diretório de categoria não encontrado: {Path}", categoryPath);
return new List<ArticleMetadata>();
}
var articles = new List<ArticleMetadata>();
var files = Directory.GetFiles(categoryPath, $"*.{culture}.md");
foreach (var file in files)
{
try
{
var content = await File.ReadAllTextAsync(file);
var (metadata, _) = ExtractFrontmatter(content);
if (metadata != null)
{
var slug = Path.GetFileNameWithoutExtension(
Path.GetFileNameWithoutExtension(file)
);
metadata.Slug = slug;
metadata.Culture = culture;
metadata.Category = category;
articles.Add(metadata);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar arquivo: {File}", file);
}
}
// Ordenar por data (mais recentes primeiro)
articles = articles.OrderByDescending(a => a.Date).ToList();
_cache.Set(cacheKey, articles, TimeSpan.FromHours(1));
return articles;
}
public async Task<List<ArticleMetadata>> GetAllArticlesAsync(string baseFolder, string culture)
{
var cacheKey = $"AllArticles_{baseFolder}_{culture}";
if (_cache.TryGetValue(cacheKey, out List<ArticleMetadata>? cached))
{
return cached ?? new List<ArticleMetadata>();
}
var folderPath = Path.Combine(_contentBasePath, baseFolder);
if (!Directory.Exists(folderPath))
{
_logger.LogWarning("Pasta não encontrada: {Path}", folderPath);
return new List<ArticleMetadata>();
}
var articles = new List<ArticleMetadata>();
var files = Directory.GetFiles(folderPath, $"*.{culture}.md", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var content = await File.ReadAllTextAsync(file);
var (metadata, _) = ExtractFrontmatter(content);
if (metadata != null)
{
var slug = Path.GetFileNameWithoutExtension(
Path.GetFileNameWithoutExtension(file)
);
metadata.Slug = slug;
metadata.Culture = culture;
articles.Add(metadata);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao processar arquivo: {File}", file);
}
}
articles = articles.OrderByDescending(a => a.Date).ToList();
_cache.Set(cacheKey, articles, TimeSpan.FromHours(1));
return articles;
}
private (ArticleMetadata? metadata, string content) ExtractFrontmatter(string fileContent)
{
var lines = fileContent.Split('\n');
if (lines.Length < 3 || !lines[0].Trim().Equals("---"))
{
_logger.LogWarning("Frontmatter não encontrado (deve começar com ---)");
return (null, fileContent);
}
var endIndex = Array.FindIndex(lines, 1, line => line.Trim().Equals("---"));
if (endIndex == -1)
{
_logger.LogWarning("Frontmatter mal formatado (falta --- de fechamento)");
return (null, fileContent);
}
try
{
var yamlContent = string.Join('\n', lines[1..endIndex]);
var metadata = _yamlDeserializer.Deserialize<ArticleMetadata>(yamlContent);
var markdownContent = string.Join('\n', lines[(endIndex + 1)..]);
return (metadata, markdownContent);
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao deserializar YAML frontmatter");
return (null, fileContent);
}
}
}

View File

@ -0,0 +1,251 @@
@model BCards.Web.Areas.Tutoriais.Models.ViewModels.ArticleViewModel
@{
var category = ViewBag.Category as BCards.Web.Models.Category;
ViewData["Title"] = Model.Metadata.Title;
}
@section Head {
<!-- Meta Tags SEO -->
<meta name="description" content="@Model.Metadata.Description">
<meta name="keywords" content="@Model.Metadata.Keywords">
<meta name="author" content="@Model.Metadata.Author">
<meta name="robots" content="index, follow">
<link rel="canonical" href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = Model.Slug }, Context.Request.Scheme)">
<!-- Open Graph -->
<meta property="og:type" content="article">
<meta property="og:title" content="@Model.Metadata.Title">
<meta property="og:description" content="@Model.Metadata.Description">
<meta property="og:image" content="@Model.Metadata.Image">
<meta property="og:url" content="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = Model.Slug }, Context.Request.Scheme)">
<meta property="article:published_time" content="@Model.Metadata.Date.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:modified_time" content="@Model.Metadata.LastMod.ToString("yyyy-MM-ddTHH:mm:ssZ")">
<meta property="article:author" content="@Model.Metadata.Author">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="@Model.Metadata.Title">
<meta name="twitter:description" content="@Model.Metadata.Description">
<meta name="twitter:image" content="@Model.Metadata.Image">
<!-- Schema.org JSON-LD -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "Article",
"headline": "@Model.Metadata.Title",
"description": "@Model.Metadata.Description",
"image": "@Model.Metadata.Image",
"datePublished": "@Model.Metadata.Date.ToString("yyyy-MM-dd")",
"dateModified": "@Model.Metadata.LastMod.ToString("yyyy-MM-dd")",
"author": {
"@@type": "Person",
"name": "@Model.Metadata.Author"
},
"publisher": {
"@@type": "Organization",
"name": "BCards",
"logo": {
"@@type": "ImageObject",
"url": "https://bcards.site/logo.png"
}
}
}
</script>
<!-- BreadcrumbList Schema -->
<script type="application/ld+json">
{
"@@context": "https://schema.org",
"@@type": "BreadcrumbList",
"itemListElement": [
{
"@@type": "ListItem",
"position": 1,
"name": "Início",
"item": "https://bcards.site"
},
{
"@@type": "ListItem",
"position": 2,
"name": "Tutoriais",
"item": "https://bcards.site/tutoriais"
},
{
"@@type": "ListItem",
"position": 3,
"name": "@category?.Name",
"item": "https://bcards.site/tutoriais/@category?.Slug"
},
{
"@@type": "ListItem",
"position": 4,
"name": "@Model.Metadata.Title"
}
]
}
</script>
}
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">Início</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })">Tutoriais</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Category", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug })">@category?.Name</a></li>
<li class="breadcrumb-item active" aria-current="page">@Model.Metadata.Title</li>
</ol>
</nav>
<!-- Article Header -->
<article>
<header class="mb-4">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">@category?.Icon @category?.Name</span>
<span class="text-muted small"><i class="far fa-clock me-1"></i> @Model.Metadata.ReadingTimeMinutes min de leitura</span>
</div>
<h1 class="display-5 mb-3">@Model.Metadata.Title</h1>
<p class="lead text-muted">@Model.Metadata.Description</p>
<div class="d-flex align-items-center text-muted small mb-3">
<span class="me-3"><i class="fas fa-user me-1"></i> @Model.Metadata.Author</span>
<span class="me-3"><i class="fas fa-calendar me-1"></i> @Model.Metadata.Date.ToString("dd/MM/yyyy")</span>
<span><i class="fas fa-sync me-1"></i> Atualizado em @Model.Metadata.LastMod.ToString("dd/MM/yyyy")</span>
</div>
</header>
@if (!string.IsNullOrEmpty(Model.Metadata.Image))
{
<img src="@Model.Metadata.Image" class="img-fluid rounded mb-4" alt="@Model.Metadata.Title">
}
<!-- Article Content -->
<div class="article-content">
@Html.Raw(Model.HtmlContent)
</div>
</article>
<!-- CTA -->
<div class="alert alert-primary mt-5" role="alert">
<h4 class="alert-heading"><i class="fas fa-rocket me-2"></i> Pronto para criar seu BCard?</h4>
<p class="mb-3">Agora que você aprendeu como funciona, que tal criar seu próprio cartão digital profissional?</p>
<a href="/" class="btn btn-primary">Criar meu BCard grátis</a>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<div class="sticky-top" style="top: 20px;">
<!-- Related Articles -->
@if (Model.RelatedArticles.Any())
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="fas fa-book-open me-2"></i> Tutoriais Relacionados</h5>
<div class="list-group list-group-flush">
@foreach (var related in Model.RelatedArticles)
{
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = related.Slug })" class="list-group-item list-group-item-action border-0 px-0">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">@related.Title</h6>
</div>
<small class="text-muted"><i class="far fa-clock me-1"></i> @related.ReadingTimeMinutes min</small>
</a>
}
</div>
</div>
</div>
}
<!-- Help Card -->
<div class="card border-0 shadow-sm bg-light">
<div class="card-body text-center">
<i class="fas fa-question-circle fa-3x text-primary mb-3"></i>
<h5 class="card-title">Precisa de ajuda?</h5>
<p class="card-text small text-muted">Entre em contato com nosso suporte se tiver dúvidas</p>
<a href="/Support" class="btn btn-sm btn-outline-primary">Falar com suporte</a>
</div>
</div>
</div>
</div>
</div>
</div>
@section Styles {
<style>
.article-content {
font-size: 1.1rem;
line-height: 1.8;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: 600;
}
.article-content h2 {
font-size: 1.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #dee2e6;
}
.article-content h3 {
font-size: 1.5rem;
}
.article-content p {
margin-bottom: 1.5rem;
}
.article-content img {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 1.5rem 0;
}
.article-content ul,
.article-content ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
.article-content li {
margin-bottom: 0.5rem;
}
.article-content code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
.article-content pre {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.article-content blockquote {
border-left: 4px solid #0d6efd;
padding-left: 1rem;
margin-left: 0;
font-style: italic;
color: #6c757d;
}
.article-content table {
width: 100%;
margin-bottom: 1.5rem;
border-collapse: collapse;
}
.article-content table th,
.article-content table td {
padding: 0.75rem;
border: 1px solid #dee2e6;
}
.article-content table th {
background-color: #f8f9fa;
font-weight: 600;
}
</style>
}

View File

@ -0,0 +1,74 @@
@model List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>
@{
var category = ViewBag.Category as BCards.Web.Models.Category;
ViewData["Title"] = $"Tutoriais de {category?.Name} - BCards";
}
<div class="container py-5">
<div class="row mb-5">
<div class="col-lg-8 mx-auto text-center">
<span class="display-1">@category?.Icon</span>
<h1 class="display-5 mb-3">Tutoriais de @category?.Name</h1>
<p class="lead text-muted">@category?.Description</p>
<nav aria-label="breadcrumb">
<ol class="breadcrumb justify-content-center">
<li class="breadcrumb-item"><a href="/">Início</a></li>
<li class="breadcrumb-item"><a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })">Tutoriais</a></li>
<li class="breadcrumb-item active" aria-current="page">@category?.Name</li>
</ol>
</nav>
</div>
</div>
@if (Model.Any())
{
<div class="row g-4">
@foreach (var artigo in Model)
{
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm hover-shadow">
@if (!string.IsNullOrEmpty(artigo.Image))
{
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
}
<div class="card-body">
<h5 class="card-title">@artigo.Title</h5>
<p class="card-text text-muted small">@artigo.Description</p>
<div class="d-flex justify-content-between align-items-center text-muted small">
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category?.Slug, slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
Ler tutorial <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="text-center py-5">
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Nenhum tutorial disponível nesta categoria</h3>
<p class="text-muted">Em breve teremos tutoriais para @category?.Name!</p>
<a href="@Url.Action("Index", "Tutoriais", new { area = "Tutoriais" })" class="btn btn-primary mt-3">
<i class="fas fa-arrow-left me-2"></i> Voltar para tutoriais
</a>
</div>
}
</div>
@section Styles {
<style>
.hover-shadow {
transition: box-shadow 0.3s ease-in-out;
}
.hover-shadow:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
}

View File

@ -0,0 +1,84 @@
@model Dictionary<string, List<BCards.Web.Areas.Tutoriais.Models.ArticleMetadata>>
@{
ViewData["Title"] = "Tutoriais BCards - Aprenda a usar o BCards";
var categories = ViewBag.Categories as List<BCards.Web.Models.Category>;
}
<div class="container py-5">
<div class="row mb-5">
<div class="col-lg-8 mx-auto text-center">
<h1 class="display-4 mb-3">📚 Tutoriais BCards</h1>
<p class="lead text-muted">Aprenda a usar o BCards e maximize seus resultados com guias práticos por categoria</p>
</div>
</div>
@if (Model.Any())
{
@foreach (var categorySlug in Model.Keys)
{
var category = categories?.FirstOrDefault(c => c.Slug == categorySlug);
var artigos = Model[categorySlug];
if (category != null && artigos.Any())
{
<div class="mb-5">
<div class="d-flex align-items-center mb-3">
<span class="fs-2 me-2">@category.Icon</span>
<h2 class="mb-0">@category.Name</h2>
<a href="@Url.Action("Category", "Tutoriais", new { area = "Tutoriais", categoria = category.Slug })" class="ms-auto btn btn-sm btn-outline-primary">
Ver todos <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
<p class="text-muted mb-4">@category.Description</p>
<div class="row g-4">
@foreach (var artigo in artigos.Take(3))
{
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm hover-shadow">
@if (!string.IsNullOrEmpty(artigo.Image))
{
<img src="@artigo.Image" class="card-img-top" alt="@artigo.Title" style="height: 200px; object-fit: cover;">
}
<div class="card-body">
<h5 class="card-title">@artigo.Title</h5>
<p class="card-text text-muted small">@artigo.Description</p>
<div class="d-flex justify-content-between align-items-center text-muted small">
<span><i class="far fa-clock me-1"></i> @artigo.ReadingTimeMinutes min</span>
<span>@artigo.Date.ToString("dd/MM/yyyy")</span>
</div>
</div>
<div class="card-footer bg-transparent border-0">
<a href="@Url.Action("Article", "Tutoriais", new { area = "Tutoriais", categoria = category.Slug, slug = artigo.Slug })" class="btn btn-sm btn-primary w-100">
Ler tutorial <i class="fas fa-arrow-right ms-1"></i>
</a>
</div>
</div>
</div>
}
</div>
</div>
<hr class="my-5">
}
}
}
else
{
<div class="text-center py-5">
<i class="fas fa-book-open fa-4x text-muted mb-3"></i>
<h3 class="text-muted">Nenhum tutorial disponível ainda</h3>
<p class="text-muted">Em breve teremos tutoriais incríveis para você!</p>
</div>
}
</div>
@section Styles {
<style>
.hover-shadow {
transition: box-shadow 0.3s ease-in-out;
}
.hover-shadow:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
}

View File

@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@ -10,6 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Markdig" Version="0.43.0" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" /> <PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Stripe.net" Version="48.4.0" /> <PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
@ -31,12 +32,18 @@
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" />
<PackageReference Include="AspNetCore.DataProtection.MongoDB" Version="8.0.0" /> <PackageReference Include="AspNetCore.DataProtection.MongoDB" Version="8.0.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\**\*.resx" /> <EmbeddedResource Include="Resources\**\*.resx" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Folder Include="Content\Artigos\" />
<Folder Include="Content\Tutoriais\" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)'=='Testing'"> <PropertyGroup Condition="'$(Configuration)'=='Testing'">
<DefineConstants>$(DefineConstants);TESTING</DefineConstants> <DefineConstants>$(DefineConstants);TESTING</DefineConstants>
</PropertyGroup> </PropertyGroup>

View File

@ -0,0 +1,10 @@
namespace BCards.Web.Configuration;
public class SupportSettings
{
public string TelegramUrl { get; set; } = string.Empty;
public string FormspreeUrl { get; set; } = string.Empty;
public List<string> EnableTelegramForPlans { get; set; } = new();
public List<string> EnableFormForPlans { get; set; } = new();
public bool EnableRatingForAllUsers { get; set; } = true;
}

View File

@ -0,0 +1,395 @@
---
title: "BCards vs LinkTree: Compare e Escolha a Melhor Alternativa Brasileira"
description: "Comparação completa entre BCards e LinkTree. Descubra qual plataforma oferece melhor custo-benefício, mais funcionalidades e suporte em português para sua página de links profissional."
keywords: "linktree, alternativa ao linktree, bcards, página de links, linktree brasil, comparação linktree, melhor que linktree"
author: "Equipe BCards"
date: 2025-01-15
lastMod: 2025-01-15
image: "/images/artigos/bcards-vs-linktree.jpg"
culture: "pt-BR"
---
# BCards vs LinkTree: Compare e Escolha a Melhor Alternativa Brasileira
Se você está procurando uma alternativa ao LinkTree, provavelmente já percebeu que existem diversas opções no mercado. Mas qual delas oferece o melhor custo-benefício para profissionais e empresas brasileiras?
Neste artigo, vamos fazer uma comparação honesta e detalhada entre **BCards** e **LinkTree**, analisando funcionalidades, preços, suporte e muito mais para ajudá-lo a tomar a melhor decisão.
## O Que São Plataformas de Bio Links?
Antes de mergulharmos na comparação, vamos entender o conceito. Plataformas como LinkTree e BCards permitem que você crie uma página única com múltiplos links, perfeita para compartilhar em suas redes sociais (especialmente Instagram, TikTok e Twitter, onde você tem espaço limitado para links).
Em vez de escolher apenas um link na sua bio, você direciona seus seguidores para uma página centralizada com todos os seus links importantes: site, blog, produtos, redes sociais, portfólio e muito mais.
## Visão Geral: BCards vs LinkTree
### LinkTree: O Pioneer Global
O LinkTree foi uma das primeiras plataformas de bio links a ganhar popularidade mundial. Fundado na Austrália, hoje é usado por milhões de criadores de conteúdo, influenciadores e empresas ao redor do mundo.
**Principais Características:**
- Interface simples e intuitiva
- Grande reconhecimento de marca internacional
- Diversas integrações com plataformas globais
- Suporte em inglês
### BCards: A Alternativa Brasileira
O BCards é uma plataforma brasileira desenvolvida especificamente para atender às necessidades do mercado nacional. Criada por profissionais que entendem os desafios locais, oferece funcionalidades pensadas para o público brasileiro.
**Principais Características:**
- Totalmente em português
- Suporte local e personalizado
- Preços em reais (sem variação cambial)
- URLs organizadas por categoria profissional
- Foco no mercado brasileiro e latino-americano
## Comparação Detalhada de Funcionalidades
### 1. Estrutura de URLs
**LinkTree:**
- Formato: `linktr.ee/seuusuario`
- URL genérica para todos os usuários
**BCards:**
- Formato: `bcards.site/page/{categoria}/{seu-slug}`
- Exemplos:
- `bcards.site/page/advocacia/maria-silva`
- `bcards.site/page/tecnologia/joao-dev`
- `bcards.site/page/saude/dra-ana-cardiologista`
**Vantagem BCards:** URLs hierárquicas melhoram significativamente o SEO e a credibilidade profissional. Quando alguém visita seu link, já sabe qual é sua área de atuação antes mesmo de abrir a página.
### 2. Personalização Visual
**LinkTree:**
- Plano gratuito: Temas básicos limitados
- Planos pagos: Mais opções de personalização
- Temas pré-definidos
**BCards:**
- Plano Básico: 5+ temas profissionais
- Plano Profissional: 10+ temas premium
- Plano Premium: Temas customizáveis + editor CSS
**Vantagem BCards:** Maior flexibilidade de personalização mesmo nos planos mais acessíveis, permitindo que sua página reflita sua identidade visual.
### 3. Quantidade de Links
**LinkTree:**
- Plano gratuito: Links ilimitados
- Destaque para 1 link por vez (feature paga)
**BCards:**
- Plano Básico (R$ 12,90): 5 links
- Plano Profissional (R$ 25,90): 15 links
- Plano Premium (R$ 29,90): Links ilimitados
**Empate:** LinkTree oferece links ilimitados gratuitamente, mas limita recursos de destaque. BCards oferece estrutura mais organizada com categorias, mas limita quantidade nos planos básicos.
### 4. Analytics e Métricas
**LinkTree:**
- Plano gratuito: Métricas básicas de cliques
- Planos pagos: Analytics avançado, integração com Google Analytics, rastreamento de conversões
**BCards:**
- Todos os planos: Contadores de cliques por link
- Dashboards com métricas de desempenho
- Relatórios de visitantes
**Empate:** Ambas as plataformas oferecem analytics suficientes para a maioria dos usuários. LinkTree tem vantagem em integrações avançadas, BCards oferece simplicidade e clareza nos dados.
### 5. Integrações
**LinkTree:**
- Integrações com diversas plataformas globais
- Facebook Pixel, Google Analytics, TikTok, Spotify, Apple Music
- Mais de 100 integrações
**BCards:**
- Integrações com principais plataformas brasileiras
- Google Analytics
- Redes sociais principais (Instagram, Facebook, WhatsApp)
- Foco em ferramentas relevantes para o mercado brasileiro
**Vantagem LinkTree:** Maior quantidade de integrações com plataformas internacionais, ideal para criadores de conteúdo global.
### 6. Sistema de Moderação e Qualidade
**LinkTree:**
- Moderação automatizada
- Políticas de uso global
**BCards:**
- Sistema de moderação humanizada
- Análise de conteúdo antes da ativação
- Garantia de qualidade das páginas públicas
**Vantagem BCards:** A moderação manual garante que todas as páginas ativas mantenham um padrão de qualidade, protegendo a reputação da plataforma e dos usuários.
## Comparação de Preços (Janeiro 2025)
### LinkTree
**Free (Gratuito):**
- Links ilimitados
- Temas básicos
- Métricas limitadas
- Branding LinkTree visível
**Starter (US$ 5/mês = ~R$ 25-30/mês*):**
- Remove branding
- Mais opções de temas
- Agendamento de links
- Analytics básico
**Pro (US$ 9/mês = ~R$ 45-55/mês*):**
- Analytics avançado
- Priorização de links
- Vídeos de fundo
- Integrações avançadas
**Premium (US$ 24/mês = ~R$ 120-145/mês*):**
- Todas as features Pro
- Suporte prioritário
- Features de e-commerce
- Mais integrações
*Valores aproximados sujeitos à variação cambial
### BCards
**Básico (R$ 12,90/mês):**
- 5 links organizados
- Temas básicos
- Analytics essenciais
- URL categorizada
- Suporte em português
**Profissional (R$ 25,90/mês):**
- 15 links organizados
- Todos os temas premium
- Analytics completo
- Suporte prioritário
**Premium (R$ 29,90/mês):**
- Links ilimitados
- Temas customizáveis
- Editor CSS avançado
- Analytics detalhado
- Suporte VIP
### Análise de Custo-Benefício
Para usuários brasileiros, o BCards oferece vantagens significativas:
1. **Sem variação cambial**: Preços fixos em reais
2. **Custo inicial menor**: R$ 12,90 vs ~R$ 25-30 (Starter do LinkTree)
3. **Plano Premium mais acessível**: R$ 29,90 vs ~R$ 120-145 (Premium do LinkTree)
4. **Suporte em português**: Sem barreira linguística
**Exemplo prático:**
Um advogado que precisa de uma página profissional com 10 links:
- **LinkTree Pro**: ~R$ 45-55/mês (R$ 540-660/ano)
- **BCards Profissional**: R$ 25,90/mês (R$ 310,80/ano)
- **Economia**: R$ 241-361/ano (44-55% de economia)
## Quando Escolher LinkTree?
O LinkTree pode ser a melhor opção se você:
1. **Precisa de visibilidade internacional**: Marca reconhecida globalmente
2. **Cria conteúdo em inglês**: Audiência internacional
3. **Necessita de integrações específicas**: Plataformas não populares no Brasil
4. **Quer começar gratuitamente**: Plano free com links ilimitados
5. **Trabalha com e-commerce global**: Integrações avançadas de vendas
## Quando Escolher BCards?
O BCards é ideal se você:
1. **Atua no mercado brasileiro**: Profissionais liberais, empresas locais
2. **Valoriza suporte em português**: Comunicação clara e rápida
3. **Busca melhor custo-benefício**: Preços competitivos em reais
4. **Quer URLs profissionais**: Estrutura categorizada melhora SEO
5. **Precisa de personalização**: Temas customizáveis por preço acessível
6. **Valoriza qualidade**: Sistema de moderação garante padrão elevado
## Casos de Uso Práticos
### Caso 1: Advogada Especializada em Direito de Família
**Escolha: BCards**
Maria Silva, advogada em São Paulo, escolheu BCards porque:
- URL profissional: `bcards.site/page/advocacia/maria-silva-direito-familia`
- Preço fixo em reais (sem surpresas)
- Suporte em português para ajustar sua página
- Sistema de moderação garante credibilidade profissional
**Resultado:** Aumento de 40% em consultas via página de bio nos primeiros 3 meses.
### Caso 2: Influenciador de Tecnologia Global
**Escolha: LinkTree**
João Tech, criador de conteúdo com audiência internacional, escolheu LinkTree porque:
- Marca reconhecida globalmente
- Integrações com Patreon, Ko-fi, e outras plataformas internacionais
- Audiência em múltiplos países
- Necessidade de features de e-commerce global
**Resultado:** Facilidade para monetização internacional e reconhecimento da marca LinkTree entre seguidores estrangeiros.
### Caso 3: Personal Trainer Local
**Escolha: BCards**
Ana Fitness, personal trainer em Belo Horizonte, escolheu BCards porque:
- Atende apenas clientes locais
- Precisava de página profissional sem custo alto
- URL categorizada: `bcards.site/page/saude/ana-personal-trainer-bh`
- Todos os clientes falam português
**Resultado:** Redução de 60% no custo mensal comparado ao LinkTree Pro, mantendo todas as funcionalidades necessárias.
## Fatores Técnicos: SEO e Performance
### SEO (Otimização para Motores de Busca)
**BCards vantagens:**
- URLs hierárquicas descritivas
- Estrutura de categorias melhora indexação
- Conteúdo em português nativo
- Menor concorrência em buscas locais
**LinkTree vantagens:**
- Autoridade de domínio global mais alta
- Maior reconhecimento de marca
- Backlinks naturais de usuários internacionais
### Performance e Velocidade
Ambas as plataformas oferecem:
- Carregamento rápido (< 2 segundos)
- Responsividade mobile
- Uptime confiável (99%+)
## Suporte ao Cliente
### LinkTree
- Suporte em inglês
- Base de conhecimento extensa
- Comunidade global ativa
- Suporte prioritário apenas em planos premium
### BCards
- Suporte em português
- Atendimento personalizado
- Suporte prioritário desde plano Profissional
- Compreensão do contexto local brasileiro
**Vantagem BCards:** Para usuários que não dominam inglês ou preferem suporte local, o BCards oferece experiência significativamente melhor.
## Segurança e Privacidade
### LinkTree
- Certificado SSL
- Conformidade com GDPR (Europa)
- Políticas de privacidade internacionais
### BCards
- Certificado SSL
- Políticas alinhadas com LGPD (Brasil)
- Dados hospedados no Brasil
**Vantagem BCards:** Para empresas que precisam estar em conformidade com LGPD, ter dados hospedados no Brasil pode ser uma vantagem regulatória.
## Limitações de Cada Plataforma
### LinkTree Limitações
- Custos em dólar (variação cambial)
- Suporte não é em português
- Alguns recursos avançados são muito caros
- Foco global pode não atender necessidades locais
### BCards Limitações
- Menor reconhecimento internacional
- Menos integrações com plataformas globais
- Comunidade menor (plataforma mais nova)
- Não tem plano gratuito com links ilimitados
## Tabela Comparativa Resumida
| Característica | LinkTree | BCards |
|---------------|----------|---------|
| **Preço inicial** | Gratuito (limitado) | R$ 12,90/mês |
| **Plano Pro** | ~R$ 45-55/mês | R$ 25,90/mês |
| **Plano Premium** | ~R$ 120-145/mês | R$ 29,90/mês |
| **Moeda** | Dólar (USD) | Real (BRL) |
| **Links ilimitados** | Grátis | R$ 29,90/mês |
| **Suporte** | Inglês | Português |
| **URL** | linktr.ee/usuario | bcards.site/page/categoria/usuario |
| **Moderação** | Automatizada | Humanizada |
| **Foco** | Global | Brasil/América Latina |
| **Integrações** | 100+ | Principais |
| **Personalização** | Boa (paga) | Excelente (todos planos) |
| **SEO** | Autoridade global | URLs categorizadas |
## Migração: Como Trocar do LinkTree para BCards
Se você já usa LinkTree e está considerando migrar para BCards, o processo é simples:
1. **Exporte seus links**: Copie títulos e URLs
2. **Crie conta no BCards**: Escolha sua categoria profissional
3. **Configure sua página**: Adicione links, escolha tema
4. **Submeta para moderação**: Aguarde aprovação (24-48h)
5. **Atualize suas redes sociais**: Troque o link da bio
**Dica:** Mantenha ambas as páginas ativas durante 1-2 semanas de transição para garantir que todos os seguidores vejam o novo link.
## Conclusão: Qual Escolher?
Não existe resposta única. A melhor escolha depende do seu contexto:
**Escolha LinkTree se:**
- Você tem audiência internacional significativa
- Cria conteúdo em inglês
- Necessita de integrações específicas globais
- Quer começar gratuitamente (com limitações)
- Reconhecimento de marca internacional é importante
**Escolha BCards se:**
- Você atua principalmente no Brasil
- Valoriza suporte em português
- Busca melhor custo-benefício
- Quer URL profissional categorizada
- Prefere moderação humanizada
- Deseja evitar variação cambial
Para a maioria dos **profissionais brasileiros, pequenas empresas e criadores de conteúdo local**, o **BCards oferece melhor custo-benefício, suporte mais personalizado e funcionalidades adequadas às necessidades do mercado nacional**.
Para **criadores de conteúdo global, influenciadores internacionais e empresas que atuam em múltiplos países**, o **LinkTree pode ser a escolha mais adequada** devido ao reconhecimento internacional da marca.
## Próximos Passos
Pronto para criar sua página de links profissional?
1. **Defina suas necessidades**: Quantos links? Qual seu público?
2. **Avalie seu orçamento**: Preço fixo ou variável?
3. **Considere suporte**: Português ou inglês?
4. **Teste a plataforma**: Crie uma página e veja qual interface prefere
5. **Decida e comece**: Ambas são boas opções, escolha a melhor para você
**Experimente BCards gratuitamente** - [Criar conta agora](https://bcards.site/)
---
**Última atualização:** Janeiro 2025
*Este artigo é baseado em informações públicas das plataformas e nossa análise independente. Os preços podem variar. Consulte os sites oficiais para informações atualizadas.*
*Disclaimer: Somos a equipe BCards, mas nos esforçamos para apresentar uma comparação honesta e imparcial. Apresentamos vantagens e limitações de ambas as plataformas para ajudá-lo a tomar a melhor decisão para seu caso específico.*

View File

@ -0,0 +1,339 @@
---
title: "Transformação Digital para Pequenos Negócios: Comece Hoje Mesmo"
description: "Guia prático sobre como pequenos negócios podem iniciar sua jornada de transformação digital sem grandes investimentos. Aprenda estratégias simples e eficazes."
keywords: "transformação digital, pequenos negócios, digitalização, presença online, marketing digital"
author: "Equipe BCards"
date: 2025-01-10
lastMod: 2025-01-10
image: "/images/artigos/transformacao-digital.jpg"
culture: "pt-BR"
---
# Transformação Digital para Pequenos Negócios: Comece Hoje Mesmo
A transformação digital não é mais um luxo reservado apenas para grandes empresas. Hoje, pequenos negócios podem (e devem) aproveitar as ferramentas digitais para crescer, alcançar novos clientes e competir no mercado moderno.
Neste artigo, você vai descobrir como iniciar sua jornada digital com investimento mínimo e resultados máximos.
## O Que É Transformação Digital?
Transformação digital é o processo de integrar tecnologias digitais em todas as áreas do seu negócio, mudando fundamentalmente como você opera e entrega valor aos clientes.
Para pequenos negócios, isso não significa gastar milhões em sistemas complexos. Significa:
- Ter presença online profissional
- Facilitar o contato com clientes
- Organizar informações de forma acessível
- Automatizar processos simples
- Usar dados para tomar decisões melhores
## Por Que Pequenos Negócios Precisam se Digitalizar?
### 1. Seus Clientes Estão Online
Mais de 70% dos brasileiros usam internet diariamente. Quando precisam de um produto ou serviço, a primeira ação é pesquisar online. Se você não está lá, está invisível.
### 2. Competitividade
Seus concorrentes já estão se digitalizando. Ficar de fora significa perder espaço no mercado.
### 3. Redução de Custos
Ferramentas digitais frequentemente custam menos que métodos tradicionais e alcançam mais pessoas.
### 4. Melhor Experiência do Cliente
Clientes valorizam conveniência: encontrar informações rapidamente, entrar em contato facilmente, agendar serviços online.
## Os 5 Pilares da Transformação Digital para Pequenos Negócios
### 1. Presença Online Profissional
**Problema:** Você não tem site, ou seu site está desatualizado.
**Solução Simples:**
- Crie uma página profissional de links (bio link)
- Organize todos seus canais em um único lugar
- Facilite o acesso a WhatsApp, Instagram, serviços
**Custo:** A partir de R$ 12,90/mês
**Resultado:** Clientes encontram suas informações facilmente, você parece mais profissional.
### 2. Relacionamento com Clientes
**Problema:** Dificuldade em manter contato constante com clientes.
**Solução Simples:**
- Use WhatsApp Business (gratuito)
- Organize contatos com etiquetas
- Configure mensagens automáticas
- Crie catálogo de produtos
**Custo:** Gratuito
**Resultado:** Comunicação mais profissional e organizada.
### 3. Divulgação Estratégica
**Problema:** Propaganda cara e ineficiente.
**Solução Simples:**
- Crie perfis profissionais em redes sociais
- Poste conteúdo relevante regularmente
- Use Instagram e Facebook Ads (começando com R$ 5/dia)
- Peça avaliações no Google Meu Negócio
**Custo:** Pode começar com R$ 0 (orgânico) ou R$ 150/mês (anúncios básicos)
**Resultado:** Mais visibilidade, novos clientes, crescimento constante.
### 4. Gestão e Organização
**Problema:** Controle manual de vendas, estoque, finanças.
**Solução Simples:**
- Use planilhas Google (gratuito)
- Adote um sistema de gestão simples (muitos têm versão gratuita)
- Organize documentos na nuvem (Google Drive/Dropbox)
**Custo:** Gratuito a R$ 30/mês
**Resultado:** Menos tempo perdido, mais controle, decisões baseadas em dados.
### 5. Pagamentos Digitais
**Problema:** Perder vendas por aceitar apenas dinheiro.
**Solução Simples:**
- Aceite Pix (gratuito)
- Use maquininha de cartão mobile
- Crie links de pagamento online
**Custo:** Taxas apenas sobre vendas (2-5%)
**Resultado:** Venda mais, facilite a vida dos clientes, profissionalize seu negócio.
## Passo a Passo: Comece Sua Transformação Digital Hoje
### Semana 1: Presença Online Básica
**Dia 1-2: Configure WhatsApp Business**
- Baixe o app WhatsApp Business
- Configure perfil profissional com horário de atendimento
- Crie mensagens automáticas
- Adicione catálogo de produtos/serviços
**Dia 3-4: Crie Perfis Profissionais**
- Instagram Business
- Facebook Page
- Google Meu Negócio
**Dia 5-7: Organize Seus Links**
- Crie página profissional de links (ex: BCards)
- Adicione todos os canais de contato
- Coloque o link na bio de todas as redes sociais
### Semana 2: Conteúdo e Engajamento
**Dias 8-10: Planeje Conteúdo**
- Defina 3 tipos de conteúdo para postar
- Exemplos: Dicas, bastidores, promoções
- Crie calendário simples (3 posts por semana)
**Dias 11-14: Comece a Postar**
- Publique primeiro conteúdo
- Responda todos os comentários
- Peça para amigos e clientes seguirem
### Semana 3: Organização Interna
**Dias 15-17: Digitalize Processos**
- Crie planilha de vendas no Google Sheets
- Liste todos os clientes em planilha de contatos
- Configure backup de fotos importantes na nuvem
**Dias 18-21: Configure Pagamentos**
- Cadastre Pix
- Avalie opções de maquininha mobile
- Crie links de pagamento
### Semana 4: Primeiras Campanhas
**Dias 22-25: Marketing Digital Básico**
- Poste sobre promoção especial
- Impulsione post no Instagram/Facebook (R$ 20)
- Monitore resultados
**Dias 26-30: Analise e Ajuste**
- Veja quais posts tiveram mais engajamento
- Identifique de onde vieram novos clientes
- Planeje próximo mês
## Ferramentas Essenciais (Maioria Gratuitas)
### Comunicação
- **WhatsApp Business**: Grátis
- **Google Workspace** (e-mail profissional): R$ 12/usuário/mês
### Organização
- **Google Drive**: 15GB grátis
- **Trello** (gestão de tarefas): Grátis
- **Google Sheets**: Grátis
### Presença Online
- **BCards** (página de links): A partir de R$ 12,90/mês
- **Instagram/Facebook**: Grátis
- **Google Meu Negócio**: Grátis
### Marketing
- **Canva** (design): Plano gratuito robusto
- **Meta Business Suite**: Grátis
- **Google Analytics**: Grátis
### Pagamentos
- **Pix**: Grátis
- **Mercado Pago**: Taxas sobre vendas
- **PagSeguro**: Taxas sobre vendas
**Investimento total inicial:** R$ 0 a R$ 50/mês
## Erros Comuns a Evitar
### 1. Tentar Fazer Tudo de Uma Vez
**Erro:** Criar 10 perfis, começar blog, loja virtual, tudo junto.
**Solução:** Comece com o básico, domine, depois expanda.
### 2. Não Ter Consistência
**Erro:** Postar muito uma semana, depois sumir por meses.
**Solução:** Defina frequência realista e mantenha.
### 3. Ignorar Clientes Online
**Erro:** Não responder comentários e mensagens rapidamente.
**Solução:** Defina horários para checar e responder (3x ao dia).
### 4. Não Mensurar Resultados
**Erro:** Investir sem saber o que funciona.
**Solução:** Acompanhe métricas básicas (seguidores, curtidas, vendas).
### 5. Copiar Concorrentes sem Personalidade
**Erro:** Ser uma cópia genérica.
**Solução:** Mostre sua personalidade, conte sua história única.
## Casos de Sucesso Reais
### Caso 1: Doceria da Dona Maria
**Antes:**
- Vendia apenas para vizinhos
- Divulgação boca a boca
- Faturamento: R$ 2.000/mês
**Ações:**
- Criou Instagram com fotos profissionais dos doces
- Configurou WhatsApp Business com catálogo
- Começou a aceitar encomendas por mensagem
- Criou página de links para facilitar contato
**Depois (6 meses):**
- Alcance em 3 bairros diferentes
- 1.200 seguidores no Instagram
- Faturamento: R$ 6.500/mês (+225%)
**Investimento:** R$ 30/mês (internet + ferramentas)
### Caso 2: Oficina do João
**Antes:**
- Clientes apenas por indicação
- Sem presença online
- Dificuldade em mostrar serviços
**Ações:**
- Criou Google Meu Negócio
- Pediu avaliações dos clientes satisfeitos
- Criou página de links com serviços e preços
- Postou fotos de antes/depois dos carros
**Depois (4 meses):**
- 5-7 novos clientes por mês via Google
- 4.8 estrelas no Google (15 avaliações)
- Aumento de 40% no faturamento
**Investimento:** R$ 0 (todas ferramentas gratuitas)
## Próximos Passos Após os Primeiros 30 Dias
### Mês 2-3: Consolidação
- Refine processos que funcionaram
- Descarte o que não deu resultado
- Aumente frequência de posts
- Comece a investir pequenas quantias em anúncios
### Mês 4-6: Expansão
- Considere criar site próprio
- Expanda para novas plataformas (TikTok, LinkedIn)
- Crie programa de fidelidade digital
- Automatize mais processos
### Mês 7-12: Maturidade
- Analise dados para decisões estratégicas
- Invista em ferramentas mais robustas
- Contrate especialistas para áreas específicas
- Expanda equipe digital se necessário
## Checklist da Transformação Digital
Use esta checklist para acompanhar seu progresso:
**Fundamentos (Primeiras 2 semanas):**
- [ ] WhatsApp Business configurado
- [ ] Instagram Business criado
- [ ] Facebook Page ativa
- [ ] Google Meu Negócio cadastrado
- [ ] Página de links profissional criada
- [ ] Todos os links atualizados nas redes sociais
**Conteúdo (Primeiro mês):**
- [ ] Calendário de conteúdo criado
- [ ] Pelo menos 12 posts publicados
- [ ] Todas as mensagens respondidas em até 24h
- [ ] Primeiras avaliações recebidas
**Organização (Primeiros 2 meses):**
- [ ] Planilha de vendas funcionando
- [ ] Backup de arquivos na nuvem
- [ ] Processos principais documentados
- [ ] Sistema de pagamento digital ativo
**Marketing (Primeiros 3 meses):**
- [ ] Pelo menos 1 campanha paga testada
- [ ] Análise de métricas semanalmente
- [ ] Estratégia de conteúdo refinada
- [ ] Base de clientes digitais crescendo
## Conclusão: O Momento É Agora
A transformação digital não é sobre tecnologia complicada ou investimentos massivos. É sobre adaptar seu negócio para o mundo moderno, onde clientes esperam:
- **Encontrar você facilmente online**
- **Entrar em contato rapidamente**
- **Ver seu trabalho/produtos**
- **Fazer negócio de forma conveniente**
Você não precisa fazer tudo perfeitamente desde o início. Precisa começar.
Cada pequeno passo digital é um passo na direção certa:
- Primeira postagem no Instagram
- Primeiro cliente que te encontrou online
- Primeira venda via mensagem
- Primeira avaliação positiva
Esses pequenos passos se acumulam. Em 6 meses, você olhará para trás e ficará surpreso com a transformação.
**Comece hoje. Seu futuro digital está a um clique de distância.**
---
**Sobre o BCards:** Ajudamos pequenos negócios e profissionais a terem presença online profissional de forma simples e acessível. Crie sua página de links agora mesmo.
[Começar minha transformação digital →](https://bcards.site/)

View File

@ -0,0 +1,435 @@
---
title: "BCards para Advogados: Guia Completo de Marketing Digital Ético"
description: "Como advogados podem usar o BCards de forma profissional e ética, respeitando as normas da OAB sobre publicidade jurídica digital."
keywords: "advogados, marketing jurídico, publicidade advocacia, OAB, marketing digital advogados"
author: "Equipe BCards"
date: 2025-01-14
lastMod: 2025-01-14
image: "/images/tutoriais/advogados-bcards.jpg"
culture: "pt-BR"
category: "advocacia"
---
# BCards para Advogados: Guia Completo de Marketing Digital Ético
O marketing digital se tornou essencial para advogados que desejam expandir sua base de clientes. No entanto, a advocacia possui regras específicas estabelecidas pela OAB sobre publicidade.
Neste guia, você aprenderá como usar o BCards de forma profissional, ética e em conformidade com o Código de Ética da OAB.
## Por Que Advogados Precisam de Presença Digital?
### Seus Clientes Pesquisam Online
Quando alguém precisa de um advogado:
1. Pesquisa no Google
2. Pede indicações em redes sociais
3. Verifica perfis e avaliações online
Se você não está online, está invisível para potenciais clientes.
### Credibilidade Profissional
Uma presença digital organizada transmite:
- Profissionalismo
- Confiabilidade
- Acessibilidade
- Modernidade
### Concorrência
Muitos advogados já estão online. Ficar de fora significa perder espaço no mercado.
## Regras da OAB Sobre Publicidade Digital
Antes de criar sua página, é fundamental conhecer as regras do **Provimento n° 205/2021** da OAB:
### ✅ Permitido:
- Informar sobre áreas de atuação
- Divulgar títulos e especializações
- Publicar conteúdo educativo
- Compartilhar experiência profissional
- Indicar formas de contato
### ❌ Proibido:
- Garantir resultados
- Captação de clientela (mercantilização)
- Publicidade agressiva ou sensacionalista
- Promessas enganosas
- Orçamento sem análise do caso
- Comparações depreciativas com colegas
**Princípio fundamental:** Discrição, sobriedade e informação precisa.
## Como Configurar Seu BCards Profissional
### 1. Informações Básicas
#### Nome da Página
Use seu nome profissional completo seguido de "Advogado(a)" ou sua especialização:
**Exemplos adequados:**
- Dra. Maria Silva - Advogada
- João Santos | Direito Empresarial
- Ana Costa - Advocacia Trabalhista
**Evite:**
- "O Melhor Advogado"
- "Ganhe Sua Causa Garantido"
- "Advogado Nota 10"
#### Slug (URL)
Crie uma URL profissional:
**Bons exemplos:**
- `bcards.site/page/advocacia/maria-silva-advogada`
- `bcards.site/page/advocacia/joao-santos-empresarial`
- `bcards.site/page/advocacia/dra-ana-costa`
#### Descrição
Seja claro, objetivo e profissional:
**Exemplo adequado:**
```
Advogada especializada em Direito de Família e Sucessões.
OAB/SP 123.456 | Mestre em Direito Civil pela USP.
Atendimento presencial e online.
```
**Evite:**
```
A melhor advogada! Ganho 99% dos casos!
Atendo urgências a qualquer hora!
```
### 2. Links Profissionais
#### Links Essenciais para Advogados
**1. WhatsApp Business**
```
Título: Agendar Consulta
URL: https://wa.me/5511999999999?text=Olá, gostaria de agendar uma consulta
```
**2. Email Profissional**
```
Título: Contato por Email
URL: mailto:contato@seuescritorio.com.br
```
**3. Site ou Blog**
```
Título: Nosso Site
URL: https://seuescritorio.com.br
```
**4. LinkedIn**
```
Título: Perfil Profissional
URL: https://linkedin.com/in/seu-perfil
```
**5. Instagram Profissional**
```
Título: Instagram Jurídico
URL: https://instagram.com/seu.escritorio
```
**6. Artigos Jurídicos**
```
Título: Artigos e Publicações
URL: Link para seu blog ou Medium
```
**7. Google Meu Negócio**
```
Título: Avaliações e Localização
URL: Link do Google Maps do escritório
```
**8. Agendamento Online** (se usar)
```
Título: Agendar Horário
URL: Link do Calendly ou sistema de agenda
```
### 3. Escolha do Tema
Para advogados, recomendamos temas sóbrios e profissionais:
- **Clássico**: Fundo branco, clean
- **Profissional**: Tons de azul marinho ou cinza
- **Minimalista**: Ultra clean e objetivo
**Evite:**
- Cores muito vibrantes
- Animações excessivas
- Designs infantis ou descontraídos demais
### 4. Foto de Perfil
Use uma foto profissional:
- Fundo neutro
- Vestimenta formal
- Boa iluminação
- Expressão profissional mas acessível
**Evite:**
- Fotos de festas
- Selfies casuais
- Fotos em ambientes inadequados
## Conteúdo para Redes Sociais (Integrando com BCards)
Seu BCards será o hub central. Nas redes sociais, compartilhe:
### Conteúdo Educativo Permitido
**1. Dicas Jurídicas Gerais**
```
"Você sabia que tem até 2 anos para reclamar de vícios aparentes
no imóvel comprado? Entenda seus direitos!"
```
**2. Explicações de Leis**
```
"A Nova Lei de Licitações trouxe mudanças importantes.
Resumo das principais alterações: [thread]"
```
**3. Mitos e Verdades**
```
"Mito ou Verdade: É preciso registrar união estável em cartório?
MITO. Entenda por quê..."
```
**4. Casos Anônimos (com aprendizado)**
```
"Caso recente (sem identificação): Cliente conseguiu reaver valor
pago indevidamente em plano de saúde. Como? [explica processo genérico]"
```
### O Que NUNCA Postar
❌ "Acabei de ganhar mais uma causa! Somos imbatíveis!"
❌ "100% de sucesso em processos trabalhistas!"
❌ "Seu processo pode valer R$ 50.000! Entre em contato!"
❌ "Melhor advogado da cidade! Comprovado!"
❌ Fotos de audiências ou fóruns sem autorização
## Estratégia de Marketing Digital Ético
### 1. Marketing de Conteúdo
Crie conteúdo valioso:
- Artigos em blog
- Posts educativos em redes
- E-books sobre temas específicos
- Vídeos explicativos (YouTube, Instagram)
**Exemplo de calendário mensal:**
- Semana 1: Post sobre mudança legislativa
- Semana 2: Dica prática sobre direitos
- Semana 3: Explicação de conceito jurídico
- Semana 4: Resposta a dúvida comum
### 2. SEO Local para Advogados
Otimize para buscas locais:
- Google Meu Negócio completo
- URL no BCards com sua cidade (se relevante)
- Conteúdo mencionando região de atuação
**Exemplo:**
```
Advogada especializada em Direito de Família em São Paulo.
Atendimento na Zona Sul e Centro.
```
### 3. Peça Avaliações (com Cuidado)
É permitido ter avaliações no Google Meu Negócio, desde que:
- Sejam espontâneas (não solicite diretamente)
- Não há oferecimento de benefícios por avaliação
- Não são fabricadas
**Forma adequada:**
Após finalizar caso bem-sucedido, você pode:
```
"Ficamos felizes em ajudá-lo(a). Caso queira, sua opinião
sobre nosso atendimento é muito valiosa."
```
### 4. Networking Digital
Conecte-se com:
- Outros advogados (não concorrentes diretos)
- Profissionais complementares (contadores, corretores)
- Associações e entidades de classe
## Estrutura Completa: Exemplo Real
### Dra. Ana Costa - Direito Trabalhista
**URL BCards:**
`bcards.site/page/advocacia/dra-ana-costa-trabalhista`
**Descrição:**
```
Advogada Trabalhista | OAB/SP 234.567
Especialista em Direitos do Trabalhador
Mestre em Direito do Trabalho pela PUC-SP
Atendimento em São Paulo e online
```
**Links na página:**
1. 📱 WhatsApp: Agendar Consulta
2. 📧 Email: contato@anacosta.adv.br
3. 🌐 Site: www.anacosta.adv.br
4. 💼 LinkedIn: Perfil Profissional
5. 📸 Instagram: @dra.anacosta.trabalhista
6. 📄 Blog: Artigos sobre Direito Trabalhista
7. 📍 Localização: Escritório no Google Maps
8. 🕒 Agendar: Sistema de agendamento online
**Bio nas redes sociais:**
```
Dra. Ana Costa | Advogada Trabalhista 👩‍⚖️
OAB/SP 234.567
Defendendo direitos dos trabalhadores
📍 São Paulo | Atendimento presencial e online
🔗 Todos os contatos: [link BCards]
```
## Ferramentas Complementares
### 1. Gestão de Processos
- Projuris (jurídico)
- Astrea (gestão processual)
### 2. Agendamento
- Calendly
- Google Calendar
### 3. Comunicação
- WhatsApp Business
- Email profissional (Gmail Workspace)
### 4. Conteúdo
- Canva (design)
- Grammarly (revisão de texto)
### 5. Analytics
- Google Analytics (site)
- Meta Business Suite (redes sociais)
- BCards Analytics (cliques nos links)
## Monitoramento e Métricas
Acompanhe mensalmente:
**No BCards:**
- Número de visitantes
- Cliques em cada link
- Horários de maior acesso
**Nas Redes Sociais:**
- Crescimento de seguidores
- Engajamento (curtidas, comentários, compartilhamentos)
- Alcance de posts
**No Negócio:**
- Novos clientes vindos do online
- Taxa de conversão (visita → consulta)
- ROI (retorno sobre investimento em ads)
## Riscos e Como Evitá-los
### Risco 1: Infração Ética
**Como evitar:**
- Revise tudo antes de publicar
- Não garanta resultados
- Seja discreto e profissional
### Risco 2: Exposição Inadequada
**Como evitar:**
- Nunca divulgue detalhes de casos reais identificáveis
- Proteja o sigilo profissional sempre
- Peça autorização antes de mencionar qualquer caso
### Risco 3: Comentários Negativos
**Como evitar:**
- Responda sempre com profissionalismo
- Não discuta casos públicos
- Se necessário, leve discussão para privado
### Risco 4: Concorrência Desleal
**Como evitar:**
- Nunca critique colegas
- Foque em seu diferencial, não em defeitos alheios
- Colabore, não compita de forma negativa
## Checklist de Conformidade OAB
Antes de publicar sua página, verifique:
- [ ] Informações são verídicas?
- [ ] Títulos e especializações estão corretos?
- [ ] Não há promessas de resultado?
- [ ] Linguagem é sóbria e profissional?
- [ ] Não há captação mercantil de clientela?
- [ ] Fotos são profissionais?
- [ ] Links levam a conteúdo apropriado?
- [ ] OAB e número de inscrição estão visíveis?
- [ ] Especialização é reconhecida pela OAB (se mencionar)?
## Dúvidas Frequentes de Advogados
**P: Posso oferecer primeira consulta gratuita?**
R: Sim, desde que seja informação verdadeira e não caracterize captação irregular.
**P: Posso mencionar clientes famosos que atendi?**
R: Não, a menos que tenha autorização expressa e por escrito do cliente.
**P: Posso colocar preços no BCards?**
R: Não é recomendado. Orçamentos devem ser feitos após análise do caso específico.
**P: Posso fazer anúncios pagos no Google/Instagram?**
R: Sim, desde que o conteúdo respeite as normas éticas da OAB.
**P: Preciso colocar minha OAB no BCards?**
R: Sim, é obrigatório identificar número e seccional da OAB.
## Conclusão
O BCards é uma ferramenta poderosa para advogados modernizarem sua presença digital mantendo a ética profissional.
**Principais vantagens para advogados:**
- ✅ URL profissional e categorizada
- ✅ Centralização de todos os contatos
- ✅ Facilita marketing de conteúdo ético
- ✅ Transmite credibilidade
- ✅ Compatível com normas da OAB
- ✅ Analytics para medir resultados
**Lembre-se:**
- Priorize discrição e sobriedade
- Foque em educar, não em vender
- Seja transparente e honesto
- Respeite sempre as normas da OAB
Sua presença digital pode ser profissional, eficiente e ética ao mesmo tempo.
**Pronto para criar sua página profissional?**
[Criar meu BCards para Advocacia →](https://bcards.site/)
---
**Referências:**
- Provimento OAB n° 205/2021
- Código de Ética e Disciplina da OAB
**Disclaimer:** Este artigo tem caráter informativo. Para dúvidas específicas sobre ética profissional, consulte a OAB de sua seccional.
**Última atualização:** Janeiro 2025

View File

@ -0,0 +1,359 @@
---
title: "Como Criar seu BCard em 5 Minutos: Tutorial Completo"
description: "Aprenda passo a passo como criar sua página profissional de links no BCards. Tutorial completo para iniciantes com capturas de tela e dicas práticas."
keywords: "tutorial bcards, criar página de links, bio link, tutorial passo a passo"
author: "Equipe BCards"
date: 2025-01-12
lastMod: 2025-01-12
image: "/images/tutoriais/criar-bcard.jpg"
culture: "pt-BR"
category: "tecnologia"
---
# Como Criar seu BCard em 5 Minutos: Tutorial Completo
Criar sua página profissional de links no BCards é mais fácil do que você imagina. Neste tutorial, vou te guiar passo a passo desde o cadastro até a publicação da sua página.
## O Que Você Vai Precisar
- Email válido
- Conta no Google ou Microsoft (para login rápido)
- 5 minutos do seu tempo
- Links que você quer compartilhar
## Passo 1: Criar Sua Conta
### 1.1. Acesse o Site
Vá para [bcards.site](https://bcards.site) e clique em **"Entrar"** no menu superior.
### 1.2. Escolha o Método de Login
Você tem duas opções:
**Opção A: Login com Google**
- Clique em "Entrar com Google"
- Selecione sua conta
- Autorize o acesso
**Opção B: Login com Microsoft**
- Clique em "Entrar com Microsoft"
- Insira suas credenciais
- Autorize o acesso
**Dica:** Usar login social é mais rápido e seguro (sem necessidade de criar nova senha).
## Passo 2: Acessar o Dashboard
Após fazer login, você será redirecionado para seu **Dashboard**. Este é o painel de controle onde você gerencia sua página.
No Dashboard você verá:
- Botão "Criar Minha Página" (se é sua primeira vez)
- Estatísticas (após criar a página)
- Links para editar sua página
## Passo 3: Criar Sua Página
### 3.1. Clique em "Criar Minha Página"
Você verá um formulário com vários campos. Vamos preencher cada um:
### 3.2. Informações Básicas
**Nome da Página:**
- Digite como você quer ser identificado
- Exemplo: "João Silva", "Maria Design", "Advocacia Santos"
- Este será o título principal da sua página
**Categoria:**
- Selecione a categoria que melhor representa sua atividade
- Exemplos disponíveis:
- Tecnologia
- Advocacia
- Saúde
- Educação
- Marketing
- E muitas outras...
**Dica:** A categoria fará parte da sua URL e ajuda no SEO.
**Slug (URL Personalizada):**
- Este será o final da sua URL
- Formato final: `bcards.site/page/{categoria}/{seu-slug}`
- Use apenas letras minúsculas, números e hifens
- Sem espaços, acentos ou caracteres especiais
**Exemplos:**
- `joao-silva-dev` → bcards.site/page/tecnologia/joao-silva-dev
- `maria-designer` → bcards.site/page/design/maria-designer
- `dra-ana-pediatra` → bcards.site/page/saude/dra-ana-pediatra
### 3.3. Descrição
Digite uma breve descrição sobre você ou seu negócio:
**Bom exemplo:**
```
Desenvolvedora Full Stack especializada em React e Node.js.
Ajudo empresas a criarem aplicações web modernas e escaláveis.
```
**Evite:**
```
Desenvolvedora
```
(muito curto, sem contexto)
**Dica:** Use 2-3 frases. Seja claro e direto.
### 3.4. Escolha do Tema Visual
Role até a seção "Tema" e selecione o visual da sua página:
**Temas disponíveis (variam por plano):**
- **Clássico**: Fundo branco limpo, ideal para profissionais
- **Dark**: Fundo escuro, moderno
- **Gradiente**: Cores vibrantes em degradê
- **Minimalista**: Ultra clean
- **Profissional**: Sóbrio e corporativo
Você pode trocar o tema depois a qualquer momento.
### 3.5. Adicionar Links
Agora vamos adicionar os links que aparecerão na sua página.
**Para adicionar um link:**
1. Clique em **"Adicionar Link"**
2. Preencha os campos:
- **Título**: Nome que aparecerá no botão
- **URL**: Endereço completo (começando com https://)
- **Ícone** (opcional): Escolha um ícone para o link
**Exemplo de Link:**
```
Título: Meu Portfólio
URL: https://meusite.com.br
Ícone: fa-briefcase
```
**Tipos de Links Comuns:**
- WhatsApp: `https://wa.me/5511999999999`
- Instagram: `https://instagram.com/seuusuario`
- Facebook: `https://facebook.com/suapagina`
- LinkedIn: `https://linkedin.com/in/seuusuario`
- YouTube: `https://youtube.com/@seucanal`
- Site: `https://seusite.com.br`
- Email: `mailto:seu@email.com`
**Dica:** Organize links por importância. Os primeiros aparecem no topo da página.
### 3.6. Reordenar Links
Você pode arrastar e soltar os links para mudar a ordem de exibição. Os links mais importantes devem ficar no topo.
## Passo 4: Personalização Avançada (Opcional)
### 4.1. Foto de Perfil
Upload de uma foto profissional (recomendado):
- Tamanho ideal: 400x400 pixels
- Formato: JPG ou PNG
- Peso: Até 2MB
### 4.2. Cores Personalizadas (Plano Premium)
Se você tem plano Premium, pode personalizar:
- Cor dos botões
- Cor do fundo
- Cor do texto
- Fontes
### 4.3. Redes Sociais
Adicione seus perfis sociais na seção específica. Eles aparecerão como ícones na sua página.
## Passo 5: Salvar e Submeter para Moderação
### 5.1. Revisar Tudo
Antes de salvar, revise:
- ✅ Nome está correto?
- ✅ URL (slug) está como você quer?
- ✅ Todos os links funcionam?
- ✅ Descrição está clara?
- ✅ Tema escolhido te agrada?
### 5.2. Salvar
Clique no botão **"Salvar Página"** no final do formulário.
### 5.3. Submeter para Moderação
Após salvar, você verá sua página em modo de **preview** (visualização).
Para torná-la pública, clique em **"Submeter para Moderação"**.
**O Que Acontece Agora?**
1. Nossa equipe revisa sua página (24-48 horas)
2. Verificamos se segue as diretrizes da comunidade
3. Você recebe email quando for aprovada
4. Sua página fica pública!
## Passo 6: Visualizar e Compartilhar
### 6.1. Token de Preview
Enquanto aguarda aprovação, você pode visualizar sua página usando o **token de preview**:
1. No Dashboard, clique em "Gerar Token de Preview"
2. Copie o link gerado
3. Abra em uma aba privada ou compartilhe com amigos
**Exemplo de link de preview:**
```
bcards.site/page/tecnologia/joao-dev?preview=ABC123XYZ
```
### 6.2. Após Aprovação
Quando sua página for aprovada:
- Ela estará acessível publicamente
- URL final: `bcards.site/page/{categoria}/{seu-slug}`
- Você pode compartilhar em suas redes sociais!
## Passo 7: Atualizar Bio das Redes Sociais
Com sua página aprovada, atualize a bio de todas as suas redes:
### Instagram
1. Vá em "Editar Perfil"
2. Cole seu link BCards no campo "Website"
3. Salve
### TikTok
1. Editar Perfil
2. Cole o link em "Bio"
3. Salve
### Twitter/X
1. Editar Perfil
2. Cole o link em "Website"
3. Salve
### LinkedIn
1. Editar Perfil
2. Cole na seção "Informações de Contato"
3. Salve
## Dicas de Boas Práticas
### ✅ Faça:
- Use foto profissional nítida
- Escreva descrição clara e objetiva
- Teste todos os links antes de publicar
- Mantenha a página atualizada
- Responda mensagens rapidamente
- Use call-to-action nos títulos dos links
### ❌ Evite:
- Links quebrados ou incorretos
- Descrição muito longa ou muito curta
- Excesso de links (foco no essencial)
- Informações enganosas
- Conteúdo que viola diretrizes
## Atualizando Sua Página Depois
Você pode editar sua página a qualquer momento:
1. Acesse o Dashboard
2. Clique em "Editar Página"
3. Faça as alterações
4. Salve
**Importante:** Mudanças significativas podem exigir nova moderação.
## Planos e Upgrades
### Plano Básico (R$ 12,90/mês)
- 5 links
- Temas básicos
- Analytics essenciais
### Plano Profissional (R$ 25,90/mês)
- 15 links
- Todos os temas
- Analytics completo
- Suporte prioritário
### Plano Premium (R$ 29,90/mês)
- Links ilimitados
- Customização total
- Temas exclusivos
- Editor CSS
- Suporte VIP
Para fazer upgrade:
1. Dashboard → "Meu Plano"
2. Escolha o plano desejado
3. Preencha dados de pagamento
4. Confirme
## Analisando Resultados
Após sua página estar ativa, acompanhe as métricas no Dashboard:
- **Total de visitantes**: Quantas pessoas acessaram
- **Cliques por link**: Quais links são mais populares
- **Origem do tráfico**: De onde vieram os visitantes
Use esses dados para otimizar sua página!
## Problemas Comuns e Soluções
### "Meu slug já está em uso"
**Solução:** Escolha outro slug. Tente adicionar seu nome, cidade ou especialidade.
### "Link não funciona"
**Solução:** Verifique se a URL começa com `https://` e está digitada corretamente.
### "Página foi rejeitada na moderação"
**Solução:** Leia o email com o motivo da rejeição, ajuste conforme orientações e resubmeta.
### "Não recebi email de aprovação"
**Solução:** Verifique spam/lixo eletrônico ou entre em contato com suporte.
## Suporte
Precisa de ajuda?
- **Email**: suporte@bcards.site
- **Horário**: Segunda a sexta, 9h às 18h
- **FAQ**: bcards.site/ajuda
- **WhatsApp**: (Link no site)
## Conclusão
Parabéns! 🎉 Agora você sabe criar sua página profissional no BCards do zero.
**Recapitulando:**
1. ✅ Criar conta
2. ✅ Preencher informações
3. ✅ Escolher tema
4. ✅ Adicionar links
5. ✅ Salvar e submeter
6. ✅ Aguardar aprovação
7. ✅ Compartilhar!
Sua presença online profissional está a apenas alguns cliques de distância.
**Pronto para começar?** [Criar meu BCard agora →](https://bcards.site/)
---
**Tempo de leitura:** 8 minutos
**Dificuldade:** Iniciante
**Última atualização:** Janeiro 2025

View File

@ -5,9 +5,13 @@ using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Configuration;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Linq;
using MongoDB.Bson;
using System.Collections.Generic;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -23,9 +27,11 @@ public class AdminController : Controller
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly ILivePageService _livePageService; private readonly ILivePageService _livePageService;
private readonly IImageStorageService _imageStorage; private readonly IImageStorageService _imageStorage;
private readonly IDocumentStorageService _documentStorage;
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly IDowngradeService _downgradeService; private readonly IDowngradeService _downgradeService;
private readonly ILogger<AdminController> _logger; private readonly ILogger<AdminController> _logger;
private readonly IConfiguration _configuration;
public AdminController( public AdminController(
IAuthService authService, IAuthService authService,
@ -36,9 +42,11 @@ public class AdminController : Controller
IEmailService emailService, IEmailService emailService,
ILivePageService livePageService, ILivePageService livePageService,
IImageStorageService imageStorage, IImageStorageService imageStorage,
IDocumentStorageService documentStorage,
IPaymentService paymentService, IPaymentService paymentService,
IDowngradeService downgradeService, IDowngradeService downgradeService,
ILogger<AdminController> logger) ILogger<AdminController> logger,
IConfiguration configuration)
{ {
_authService = authService; _authService = authService;
_userPageService = userPageService; _userPageService = userPageService;
@ -48,9 +56,11 @@ public class AdminController : Controller
_emailService = emailService; _emailService = emailService;
_livePageService = livePageService; _livePageService = livePageService;
_imageStorage = imageStorage; _imageStorage = imageStorage;
_documentStorage = documentStorage;
_paymentService = paymentService; _paymentService = paymentService;
_downgradeService = downgradeService; _downgradeService = downgradeService;
_logger = logger; _logger = logger;
_configuration = configuration;
} }
@ -175,7 +185,11 @@ public class AdminController : Controller
AvailableCategories = categories, AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
AllowProductLinks = planLimitations.AllowProductLinks AllowProductLinks = planLimitations.AllowProductLinks,
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
MaxDocumentsAllowed = planLimitations.MaxDocuments,
Documents = new List<ManageDocumentViewModel>(),
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
}; };
return View(model); return View(model);
} }
@ -214,6 +228,12 @@ public class AdminController : Controller
return RedirectToAction("Login", "Auth"); return RedirectToAction("Login", "Auth");
userId = user.Id; userId = user.Id;
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var parsedPlan) ? parsedPlan : PlanType.Trial;
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
// Limpar campos de redes sociais que são apenas espaços (tratados como vazios) // Limpar campos de redes sociais que são apenas espaços (tratados como vazios)
CleanSocialMediaFields(model); CleanSocialMediaFields(model);
@ -255,13 +275,13 @@ public class AdminController : Controller
TempData["ImageError"] = errorMessage; TempData["ImageError"] = errorMessage;
// Preservar dados do form e repopular dropdowns // Preservar dados do form e repopular dropdowns
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(); model.MaxLinksAllowed = userPlanType.GetMaxLinksPerPage();
model.AllowProductLinks = planLimitations.AllowProductLinks; model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
// Preservar ProfileImageId existente se estava editando // Preservar ProfileImageId existente se estava editando
if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id)) if (!model.IsNewPage && !string.IsNullOrEmpty(model.Id))
@ -277,6 +297,8 @@ public class AdminController : Controller
} }
} }
var (processedDocuments, removedFileIds, newFileIds) = await BuildDocumentsAsync(model, planLimitations);
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
var sbError = new StringBuilder(); var sbError = new StringBuilder();
@ -297,6 +319,10 @@ public class AdminController : Controller
model.Slug = slug; model.Slug = slug;
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.AllowProductLinks = planLimitations.AllowProductLinks;
model.AllowDocumentUpload = planLimitations.AllowDocumentUpload;
model.MaxDocumentsAllowed = planLimitations.MaxDocuments;
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -304,7 +330,6 @@ public class AdminController : Controller
{ {
// CRITICAL: Check if user can create new page (validate MaxPages limit) // CRITICAL: Check if user can create new page (validate MaxPages limit)
var existingPages = await _userPageService.GetUserPagesAsync(user.Id); var existingPages = await _userPageService.GetUserPagesAsync(user.Id);
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var maxPages = userPlanType.GetMaxPages(); var maxPages = userPlanType.GetMaxPages();
if (existingPages.Count >= maxPages) if (existingPages.Count >= maxPages)
@ -312,6 +337,7 @@ public class AdminController : Controller
TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas."; TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas.";
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -327,6 +353,7 @@ public class AdminController : Controller
ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra."); ModelState.AddModelError("Slug", "Esta URL já está em uso. Tente outra.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -337,6 +364,7 @@ public class AdminController : Controller
ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual."); ModelState.AddModelError("", $"Você excedeu o limite de {model.MaxLinksAllowed} links do seu plano atual.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
return View(model); return View(model);
} }
@ -344,6 +372,7 @@ public class AdminController : Controller
{ {
// Create new page // Create new page
var userPage = await MapToUserPage(model, user.Id); var userPage = await MapToUserPage(model, user.Id);
userPage.Documents = processedDocuments;
_logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}"); _logger.LogInformation($"Mapped to UserPage: {userPage.DisplayName}, Category: {userPage.Category}, Slug: {userPage.Slug}");
// Set status to Creating for new pages // Set status to Creating for new pages
@ -362,6 +391,7 @@ public class AdminController : Controller
ModelState.AddModelError("", "Erro ao criar página. Tente novamente."); ModelState.AddModelError("", "Erro ao criar página. Tente novamente.");
model.AvailableCategories = await _categoryService.GetAllCategoriesAsync(); model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
model.AvailableThemes = await _themeService.GetAvailableThemesAsync(); model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
model.DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay();
TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}"; TempData["Error"] = $"Erro ao criar página. Tente novamente. TechMsg: {ex.Message}";
return View(model); return View(model);
} }
@ -371,7 +401,15 @@ public class AdminController : Controller
// Update existing page // Update existing page
var existingPage = await _userPageService.GetPageByIdAsync(model.Id); var existingPage = await _userPageService.GetPageByIdAsync(model.Id);
if (existingPage == null || existingPage.UserId != user.Id) if (existingPage == null || existingPage.UserId != user.Id)
{
await CleanupNewDocumentsAsync(newFileIds);
return NotFound(); return NotFound();
}
if (!planLimitations.AllowDocumentUpload && existingPage.Documents?.Any() == true)
{
removedFileIds.AddRange(existingPage.Documents.Select(d => d.FileId));
}
// Check if user can create pages (for users with rejected pages) // Check if user can create pages (for users with rejected pages)
var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id); var canCreatePage = await _moderationService.CanUserCreatePageAsync(user.Id);
@ -397,7 +435,7 @@ public class AdminController : Controller
} }
} }
await UpdateUserPageFromModel(existingPage, model); await UpdateUserPageFromModel(existingPage, model, processedDocuments);
// Set status to PendingModeration for updates // Set status to PendingModeration for updates
existingPage.Status = ViewModels.PageStatus.Creating; existingPage.Status = ViewModels.PageStatus.Creating;
@ -405,6 +443,21 @@ public class AdminController : Controller
await _userPageService.UpdatePageAsync(existingPage); await _userPageService.UpdatePageAsync(existingPage);
if (removedFileIds.Count > 0)
{
foreach (var fileId in removedFileIds)
{
try
{
await _documentStorage.DeleteDocumentAsync(fileId);
}
catch (Exception cleanupEx)
{
_logger.LogWarning(cleanupEx, "Erro ao remover documento antigo {FileId}", fileId);
}
}
}
// Token será gerado apenas quando usuário clicar "Testar Página" // Token será gerado apenas quando usuário clicar "Testar Página"
// Send email to user // Send email to user
@ -745,6 +798,8 @@ public class AdminController : Controller
private async Task<ManagePageViewModel> MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType) private async Task<ManagePageViewModel> MapToManageViewModel(UserPage page, List<Category> categories, List<PageTheme> themes, PlanType userPlanType)
{ {
var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString());
return new ManagePageViewModel return new ManagePageViewModel
{ {
Id = page.Id, Id = page.Id,
@ -772,10 +827,23 @@ public class AdminController : Controller
ProductDescription = l.ProductDescription, ProductDescription = l.ProductDescription,
ProductDataCachedAt = l.ProductDataCachedAt ProductDataCachedAt = l.ProductDataCachedAt
}).ToList() ?? new List<ManageLinkViewModel>(), }).ToList() ?? new List<ManageLinkViewModel>(),
Documents = page.Documents?.Select((d, index) => new ManageDocumentViewModel
{
Id = string.IsNullOrEmpty(d.Id) ? $"doc_{index}" : d.Id,
DocumentId = d.FileId,
Title = d.Title,
Description = d.Description,
FileName = d.FileName,
FileSize = d.FileSize,
UploadedAt = d.UploadedAt
}).ToList() ?? new List<ManageDocumentViewModel>(),
AvailableCategories = categories, AvailableCategories = categories,
AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(),
MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(),
AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks AllowProductLinks = planLimitations.AllowProductLinks,
AllowDocumentUpload = planLimitations.AllowDocumentUpload,
MaxDocumentsAllowed = planLimitations.MaxDocuments,
DocumentUploadPlansDisplay = GetDocumentUploadPlansDisplay()
}; };
} }
@ -922,7 +990,198 @@ public class AdminController : Controller
return userPage; return userPage;
} }
private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model) private string GetDocumentUploadPlansDisplay()
{
var sections = _configuration.GetSection("Plans").GetChildren();
var friendlyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var section in sections)
{
if (!bool.TryParse(section["AllowDocumentUpload"], out var allow) || !allow)
continue;
var key = section.Key ?? string.Empty;
var normalizedKey = key.EndsWith("Yearly", StringComparison.OrdinalIgnoreCase)
? key[..^6]
: key;
if (Enum.TryParse<PlanType>(normalizedKey, true, out var planType))
{
friendlyNames.Add(planType.GetDisplayName());
}
else
{
var display = section["Name"] ?? key;
if (!string.IsNullOrWhiteSpace(display))
{
friendlyNames.Add(display);
}
}
}
if (friendlyNames.Count == 0)
return "planos com suporte a documentos";
var orderedNames = friendlyNames.OrderBy(name => name).ToList();
return orderedNames.Count switch
{
1 => orderedNames[0],
2 => string.Join(" e ", orderedNames),
_ => $"{string.Join(", ", orderedNames.Take(orderedNames.Count - 1))} e {orderedNames.Last()}"
};
}
private async Task<(List<PageDocument> Documents, List<string> RemovedFileIds, List<string> NewlyUploadedFileIds)> BuildDocumentsAsync(ManagePageViewModel model, PlanLimitations planLimitations)
{
var documents = new List<PageDocument>();
var removedFileIds = new List<string>();
var newFileIds = new List<string>();
if (!planLimitations.AllowDocumentUpload)
{
return (documents, removedFileIds, newFileIds);
}
if (model.Documents == null || model.Documents.Count == 0)
return (documents, removedFileIds, newFileIds);
for (int index = 0; index < model.Documents.Count; index++)
{
var docVm = model.Documents[index];
if (docVm == null)
continue;
var hasExisting = !string.IsNullOrEmpty(docVm.DocumentId);
if (docVm.MarkForRemoval)
{
if (hasExisting && docVm.DocumentId != null)
removedFileIds.Add(docVm.DocumentId);
continue;
}
if (string.IsNullOrWhiteSpace(docVm.Title))
{
ModelState.AddModelError($"Documents[{index}].Title", "Título é obrigatório");
continue;
}
string fileId = docVm.DocumentId?.Trim() ?? string.Empty;
string fileName = docVm.FileName?.Trim() ?? string.Empty;
long fileSize = docVm.FileSize;
var uploadedAt = docVm.UploadedAt ?? DateTime.UtcNow;
if (hasExisting && string.IsNullOrEmpty(docVm.Id))
{
docVm.Id = ObjectId.GenerateNewId().ToString();
}
if (docVm.DocumentFile != null && docVm.DocumentFile.Length > 0)
{
if (!docVm.DocumentFile.ContentType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase))
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Envie um arquivo em PDF.");
continue;
}
if (docVm.DocumentFile.Length > 10 * 1024 * 1024)
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Arquivo muito grande. Tamanho máximo: 10MB.");
continue;
}
using var memoryStream = new MemoryStream();
await docVm.DocumentFile.CopyToAsync(memoryStream);
var documentBytes = memoryStream.ToArray();
try
{
fileId = await _documentStorage.SaveDocumentAsync(documentBytes, docVm.DocumentFile.FileName, docVm.DocumentFile.ContentType);
fileName = docVm.DocumentFile.FileName;
fileSize = docVm.DocumentFile.Length;
uploadedAt = DateTime.UtcNow;
newFileIds.Add(fileId);
if (hasExisting && docVm.DocumentId != null)
removedFileIds.Add(docVm.DocumentId);
}
catch (ArgumentException ex)
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", ex.Message);
continue;
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao salvar PDF do usuário");
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Erro ao salvar o PDF. Tente novamente.");
continue;
}
}
else if (!hasExisting)
{
// Nenhum arquivo associado: ignorar
continue;
}
if (string.IsNullOrEmpty(fileId))
{
ModelState.AddModelError($"Documents[{index}].DocumentFile", "Falha ao processar o documento.");
continue;
}
documents.Add(new PageDocument
{
Id = string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _) ? ObjectId.GenerateNewId().ToString() : docVm.Id,
FileId = fileId,
Title = docVm.Title,
Description = docVm.Description,
FileName = fileName,
FileSize = fileSize,
UploadedAt = uploadedAt
});
docVm.DocumentId = fileId;
docVm.FileName = fileName;
docVm.FileSize = fileSize;
docVm.UploadedAt = uploadedAt;
if (string.IsNullOrEmpty(docVm.Id) || !ObjectId.TryParse(docVm.Id, out _))
{
docVm.Id = documents.Last().Id;
}
}
if (planLimitations.MaxDocuments > 0 && documents.Count > planLimitations.MaxDocuments)
{
ModelState.AddModelError("Documents", $"Você pode enviar no máximo {planLimitations.MaxDocuments} documento(s) com seu plano.");
}
return (documents, removedFileIds, newFileIds);
}
private async Task CleanupNewDocumentsAsync(IEnumerable<string> documentIds)
{
if (documentIds == null)
return;
foreach (var docId in documentIds)
{
if (string.IsNullOrEmpty(docId))
continue;
try
{
await _documentStorage.DeleteDocumentAsync(docId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao limpar documento temporário {DocumentId}", docId);
}
}
}
private async Task UpdateUserPageFromModel(UserPage page, ManagePageViewModel model, List<PageDocument> documents)
{ {
page.DisplayName = model.DisplayName; page.DisplayName = model.DisplayName;
page.Category = model.Category; page.Category = model.Category;
@ -1077,6 +1336,8 @@ public class AdminController : Controller
} }
page.Links.AddRange(socialLinks); page.Links.AddRange(socialLinks);
page.Documents = documents ?? new List<PageDocument>();
} }
[HttpPost] [HttpPost]
@ -1430,4 +1691,4 @@ public class AdminController : Controller
} }
} }
} }
} }

View File

@ -0,0 +1,64 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("api/[controller]")]
[ApiController]
public class DocumentController : ControllerBase
{
private readonly IDocumentStorageService _documentStorage;
private readonly ILogger<DocumentController> _logger;
public DocumentController(IDocumentStorageService documentStorage, ILogger<DocumentController> logger)
{
_documentStorage = documentStorage;
_logger = logger;
}
[HttpGet("{documentId}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> GetDocument(string documentId)
{
try
{
if (string.IsNullOrEmpty(documentId))
return BadRequest("Documento inválido.");
var documentBytes = await _documentStorage.GetDocumentAsync(documentId);
if (documentBytes == null)
return NotFound();
Response.Headers["Cache-Control"] = "public, max-age=31536000";
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
Response.Headers["ETag"] = $"\"{documentId}\"";
return File(documentBytes, "application/pdf");
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao obter documento {DocumentId}", documentId);
return NotFound();
}
}
[HttpDelete("{documentId}")]
public async Task<IActionResult> DeleteDocument(string documentId)
{
if (string.IsNullOrEmpty(documentId))
return BadRequest();
var deleted = await _documentStorage.DeleteDocumentAsync(documentId);
return deleted ? Ok(new { success = true }) : NotFound();
}
[HttpHead("{documentId}")]
public async Task<IActionResult> DocumentExists(string documentId)
{
if (string.IsNullOrEmpty(documentId))
return BadRequest();
var exists = await _documentStorage.DocumentExistsAsync(documentId);
return exists ? Ok() : NotFound();
}
}

View File

@ -73,7 +73,7 @@ public class SubscriptionController : Controller
{ {
case "immediate_with_refund": case "immediate_with_refund":
success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true); success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true);
message = success ? "Assinatura cancelada. Reembolso será processado manualmente em até 10 dias úteis." : "Erro ao processar cancelamento."; message = success ? "Assinatura cancelada com reembolso total. O valor será retornado em até 10 dias úteis." : "Erro ao processar cancelamento.";
break; break;
case "immediate_no_refund": case "immediate_no_refund":
@ -86,7 +86,7 @@ public class SubscriptionController : Controller
var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId); var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId);
if (success && canRefundPartial && refundAmount > 0) if (success && canRefundPartial && refundAmount > 0)
{ {
message = $"Assinatura cancelada. Reembolso parcial de R$ {refundAmount:F2} será processado manualmente em até 10 dias úteis."; message = $"Assinatura cancelada com reembolso parcial de R$ {refundAmount:F2}. O valor será retornado em até 10 dias úteis.";
} }
else else
{ {

View File

@ -149,7 +149,9 @@ public class TestToolsController : ControllerBase
AllowProductLinks = source.AllowProductLinks, AllowProductLinks = source.AllowProductLinks,
SpecialModeration = source.SpecialModeration, SpecialModeration = source.SpecialModeration,
OGExtractionsUsedToday = 0, OGExtractionsUsedToday = 0,
LastExtractionDate = null LastExtractionDate = null,
AllowDocumentUpload = source.AllowDocumentUpload,
MaxDocuments = source.MaxDocuments
}; };
} }

View File

@ -90,7 +90,9 @@ public class PlanLimitationMiddleware
AllowCustomDomain = false, AllowCustomDomain = false,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
PlanType = "basic" PlanType = "basic",
AllowDocumentUpload = false,
MaxDocuments = 0
}, },
"professional" => new Models.PlanLimitations "professional" => new Models.PlanLimitations
{ {
@ -100,7 +102,9 @@ public class PlanLimitationMiddleware
AllowCustomDomain = true, AllowCustomDomain = true,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
PlanType = "professional" PlanType = "professional",
AllowDocumentUpload = false,
MaxDocuments = 0
}, },
"premium" => new Models.PlanLimitations "premium" => new Models.PlanLimitations
{ {
@ -110,7 +114,21 @@ public class PlanLimitationMiddleware
AllowCustomDomain = true, AllowCustomDomain = true,
AllowMultipleDomains = true, AllowMultipleDomains = true,
PrioritySupport = true, PrioritySupport = true,
PlanType = "premium" PlanType = "premium",
AllowDocumentUpload = true,
MaxDocuments = 5
},
"premiumaffiliate" => new Models.PlanLimitations
{
MaxLinks = -1,
AllowCustomThemes = true,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
PlanType = "premiumaffiliate",
AllowDocumentUpload = true,
MaxDocuments = 10
}, },
_ => new Models.PlanLimitations _ => new Models.PlanLimitations
{ {
@ -120,8 +138,10 @@ public class PlanLimitationMiddleware
AllowCustomDomain = false, AllowCustomDomain = false,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
PlanType = "free" PlanType = "free",
AllowDocumentUpload = false,
MaxDocuments = 0
} }
}; };
} }
} }

View File

@ -12,12 +12,13 @@
string Slug { get; } string Slug { get; }
string DisplayName { get; } string DisplayName { get; }
string Bio { get; } string Bio { get; }
string? ProfileImageId { get; } string? ProfileImageId { get; }
string BusinessType { get; } string BusinessType { get; }
PageTheme Theme { get; } PageTheme Theme { get; }
List<LinkItem> Links { get; } List<LinkItem> Links { get; }
SeoSettings SeoSettings { get; } List<PageDocument> Documents { get; }
string Language { get; } SeoSettings SeoSettings { get; }
string Language { get; }
DateTime CreatedAt { get; } DateTime CreatedAt { get; }
// Propriedades calculadas comuns // Propriedades calculadas comuns

View File

@ -44,8 +44,11 @@ public class LivePage : IPageDisplay
[BsonElement("theme")] [BsonElement("theme")]
public PageTheme Theme { get; set; } = new(); public PageTheme Theme { get; set; } = new();
[BsonElement("links")] [BsonElement("links")]
public List<LinkItem> Links { get; set; } = new(); public List<LinkItem> Links { get; set; } = new();
[BsonElement("documents")]
public List<PageDocument> Documents { get; set; } = new();
[BsonElement("seoSettings")] [BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new(); public SeoSettings SeoSettings { get; set; } = new();
@ -86,4 +89,4 @@ public class LivePageAnalytics
[BsonElement("lastViewedAt")] [BsonElement("lastViewedAt")]
public DateTime? LastViewedAt { get; set; } public DateTime? LastViewedAt { get; set; }
} }

View File

@ -0,0 +1,30 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace BCards.Web.Models;
public class PageDocument
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
[BsonElement("fileId")]
[BsonRepresentation(BsonType.ObjectId)]
public string FileId { get; set; } = string.Empty;
[BsonElement("title")]
public string Title { get; set; } = string.Empty;
[BsonElement("description")]
public string Description { get; set; } = string.Empty;
[BsonElement("fileName")]
public string FileName { get; set; } = string.Empty;
[BsonElement("fileSize")]
public long FileSize { get; set; }
[BsonElement("uploadedAt")]
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -32,15 +32,21 @@ public class PlanLimitations
[BsonElement("maxOGExtractionsPerDay")] [BsonElement("maxOGExtractionsPerDay")]
public int MaxOGExtractionsPerDay { get; set; } = 0; public int MaxOGExtractionsPerDay { get; set; } = 0;
[BsonElement("allowProductLinks")] [BsonElement("allowProductLinks")]
public bool AllowProductLinks { get; set; } = false; public bool AllowProductLinks { get; set; } = false;
[BsonElement("specialModeration")] [BsonElement("specialModeration")]
public bool? SpecialModeration { get; set; } = false; public bool? SpecialModeration { get; set; } = false;
[BsonElement("ogExtractionsUsedToday")] [BsonElement("ogExtractionsUsedToday")]
public int OGExtractionsUsedToday { get; set; } = 0; public int OGExtractionsUsedToday { get; set; } = 0;
[BsonElement("lastExtractionDate")] [BsonElement("lastExtractionDate")]
public DateTime? LastExtractionDate { get; set; } public DateTime? LastExtractionDate { get; set; }
}
[BsonElement("allowDocumentUpload")]
public bool AllowDocumentUpload { get; set; } = false;
[BsonElement("maxDocuments")]
public int MaxDocuments { get; set; } = 0;
}

View File

@ -28,16 +28,16 @@ public static class PlanTypeExtensions
// Este método mantém valores fallback para compatibilidade // Este método mantém valores fallback para compatibilidade
public static decimal GetPrice(this PlanType planType) public static decimal GetPrice(this PlanType planType)
{ {
return planType switch return planType switch
{ {
PlanType.Trial => 0.00m, PlanType.Trial => 0.00m,
PlanType.Basic => 5.90m, PlanType.Basic => 12.90m,
PlanType.Professional => 12.90m, PlanType.Professional => 25.90m,
PlanType.Premium => 19.90m, PlanType.Premium => 29.90m,
PlanType.PremiumAffiliate => 29.90m, PlanType.PremiumAffiliate => 34.90m,
_ => 0.00m _ => 0.00m
}; };
} }
// NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService // NOTA: Limitações agora são configuradas dinamicamente via IPlanConfigurationService
// Este método mantém valores fallback para compatibilidade // Este método mantém valores fallback para compatibilidade
@ -135,4 +135,4 @@ public static class PlanTypeExtensions
{ {
return GetMaxProductLinks(planType) > 0; return GetMaxProductLinks(planType) > 0;
} }
} }

View File

@ -41,8 +41,11 @@ public class UserPage : IPageDisplay
[BsonElement("theme")] [BsonElement("theme")]
public PageTheme Theme { get; set; } = new(); public PageTheme Theme { get; set; } = new();
[BsonElement("links")] [BsonElement("links")]
public List<LinkItem> Links { get; set; } = new(); public List<LinkItem> Links { get; set; } = new();
[BsonElement("documents")]
public List<PageDocument> Documents { get; set; } = new();
[BsonElement("seoSettings")] [BsonElement("seoSettings")]
public SeoSettings SeoSettings { get; set; } = new(); public SeoSettings SeoSettings { get; set; } = new();
@ -113,4 +116,4 @@ public class UserPage : IPageDisplay
public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId) public string ProfileImageUrl => !string.IsNullOrEmpty(ProfileImageId)
? $"/api/image/{ProfileImageId}" ? $"/api/image/{ProfileImageId}"
: "/images/default-avatar.svg"; : "/images/default-avatar.svg";
} }

View File

@ -483,24 +483,37 @@ builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IDowngradeService, DowngradeService>(); builder.Services.AddScoped<IDowngradeService, DowngradeService>();
builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>(); builder.Services.AddScoped<IImageStorageService, GridFSImageStorage>();
builder.Services.AddScoped<IDocumentStorageService, GridFSDocumentStorage>();
// Support Area - Rating and Contact System
builder.Services.Configure<BCards.Web.Configuration.SupportSettings>(
builder.Configuration.GetSection("Support"));
builder.Services.AddScoped<BCards.Web.Areas.Support.Repositories.IRatingRepository, BCards.Web.Areas.Support.Repositories.RatingRepository>();
builder.Services.AddScoped<BCards.Web.Areas.Support.Services.IRatingService, BCards.Web.Areas.Support.Services.RatingService>();
builder.Services.AddScoped<BCards.Web.Areas.Support.Services.ISupportService, BCards.Web.Areas.Support.Services.SupportService>();
// Markdown/Articles System - Tutoriais e Artigos Areas
builder.Services.AddScoped<BCards.Web.Areas.Tutoriais.Services.IMarkdownService, BCards.Web.Areas.Tutoriais.Services.MarkdownService>();
// Configure upload limits for file handling (images up to 5MB) // Configure upload limits for file handling (images up to 5MB)
builder.Services.Configure<FormOptions>(options => builder.Services.Configure<FormOptions>(options =>
{ {
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB var maxUploadSize = 12 * 1024 * 1024; // 12MB
options.MultipartBodyLengthLimit = maxUploadSize;
options.ValueLengthLimit = int.MaxValue; options.ValueLengthLimit = int.MaxValue;
options.ValueCountLimit = int.MaxValue; options.ValueCountLimit = int.MaxValue;
options.KeyLengthLimit = int.MaxValue; options.KeyLengthLimit = int.MaxValue;
options.BufferBody = true; options.BufferBody = true;
options.BufferBodyLengthLimit = 5 * 1024 * 1024; // 5MB options.BufferBodyLengthLimit = maxUploadSize;
options.MultipartBodyLengthLimit = 5 * 1024 * 1024; // 5MB options.MultipartBodyLengthLimit = maxUploadSize;
options.MultipartHeadersLengthLimit = 16384; options.MultipartHeadersLengthLimit = 16384;
}); });
// Configure Kestrel server limits for larger requests // Configure Kestrel server limits for larger requests
builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options => builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options =>
{ {
options.Limits.MaxRequestBodySize = 5 * 1024 * 1024; // 5MB options.Limits.MaxRequestBodySize = 12 * 1024 * 1024; // 12MB
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2); options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(2);
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2); options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
}); });
@ -721,6 +734,12 @@ if (app.Environment.IsDevelopment())
app.UseResponseCaching(); app.UseResponseCaching();
// Support Area Routes
app.MapAreaControllerRoute(
name: "support-area",
areaName: "Support",
pattern: "Support/{controller=Support}/{action=Index}/{id?}");
app.MapControllerRoute( app.MapControllerRoute(
name: "userpage-preview-path", name: "userpage-preview-path",
pattern: "page/preview/{category}/{slug}", pattern: "page/preview/{category}/{slug}",
@ -772,6 +791,56 @@ app.MapControllerRoute(
pattern: "terminos", pattern: "terminos",
defaults: new { controller = "Legal", action = "TermsES" }); defaults: new { controller = "Legal", action = "TermsES" });
// ========================================
// AREAS: Tutoriais e Artigos
// ========================================
// IMPORTANTE: Ordem importa! Rotas mais específicas primeiro.
// Artigos Area - Índice (ANTES para evitar conflito)
app.MapAreaControllerRoute(
name: "artigos-index",
areaName: "Artigos",
pattern: "artigos",
defaults: new { controller = "Artigos", action = "Index" });
// Artigos Area - Artigo específico
app.MapAreaControllerRoute(
name: "artigos-article",
areaName: "Artigos",
pattern: "artigos/{slug}",
defaults: new { controller = "Artigos", action = "Article" },
constraints: new { slug = @"^[a-z0-9\-]+$" });
// Tutoriais Area - Índice
app.MapAreaControllerRoute(
name: "tutoriais-index",
areaName: "Tutoriais",
pattern: "tutoriais",
defaults: new { controller = "Tutoriais", action = "Index" });
// Tutoriais Area - Lista de artigos por categoria
app.MapAreaControllerRoute(
name: "tutoriais-category",
areaName: "Tutoriais",
pattern: "tutoriais/{categoria}",
defaults: new { controller = "Tutoriais", action = "Category" },
constraints: new { categoria = @"^[a-z\-]+$" });
// Tutoriais Area - Artigo específico
app.MapAreaControllerRoute(
name: "tutoriais-article",
areaName: "Tutoriais",
pattern: "tutoriais/{categoria}/{slug}",
defaults: new { controller = "Tutoriais", action = "Article" },
constraints: new
{
categoria = @"^[a-z\-]+$", // slug de categoria
slug = @"^[a-z0-9\-]+$" // slug do artigo
});
// ========================================
// Rota default
// ========================================
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"); pattern: "{controller=Home}/{action=Index}/{id?}");

View File

@ -0,0 +1,93 @@
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.GridFS;
namespace BCards.Web.Services;
public class GridFSDocumentStorage : IDocumentStorageService
{
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private static readonly string[] ALLOWED_TYPES = { "application/pdf" };
private readonly GridFSBucket _gridFS;
private readonly ILogger<GridFSDocumentStorage> _logger;
public GridFSDocumentStorage(IMongoDatabase database, ILogger<GridFSDocumentStorage> logger)
{
_gridFS = new GridFSBucket(database, new GridFSBucketOptions
{
BucketName = "page_documents"
});
_logger = logger;
}
public async Task<string> SaveDocumentAsync(byte[] documentBytes, string fileName, string contentType)
{
if (documentBytes == null || documentBytes.Length == 0)
throw new ArgumentException("Documento inválido.");
if (documentBytes.Length > MAX_FILE_SIZE)
throw new ArgumentException($"Arquivo muito grande. Tamanho máximo permitido: {MAX_FILE_SIZE / (1024 * 1024)}MB.");
if (!ALLOWED_TYPES.Contains(contentType.ToLower()))
throw new ArgumentException("Tipo de arquivo não suportado. Envie um PDF.");
var uniqueFileName = $"document_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.pdf";
var options = new GridFSUploadOptions
{
Metadata = new BsonDocument
{
{ "originalFileName", fileName },
{ "contentType", contentType },
{ "uploadDate", DateTime.UtcNow },
{ "size", documentBytes.Length }
}
};
var fileId = await _gridFS.UploadFromBytesAsync(uniqueFileName, documentBytes, options);
_logger.LogInformation("PDF salvo no GridFS: {FileId}", fileId);
return fileId.ToString();
}
public async Task<byte[]?> GetDocumentAsync(string documentId)
{
if (!ObjectId.TryParse(documentId, out var objectId))
return null;
try
{
return await _gridFS.DownloadAsBytesAsync(objectId);
}
catch (GridFSFileNotFoundException)
{
return null;
}
}
public async Task<bool> DeleteDocumentAsync(string documentId)
{
if (!ObjectId.TryParse(documentId, out var objectId))
return false;
try
{
await _gridFS.DeleteAsync(objectId);
return true;
}
catch (GridFSFileNotFoundException)
{
return false;
}
}
public async Task<bool> DocumentExistsAsync(string documentId)
{
if (!ObjectId.TryParse(documentId, out var objectId))
return false;
var filter = Builders<GridFSFileInfo>.Filter.Eq("_id", objectId);
var fileInfo = await _gridFS.Find(filter).FirstOrDefaultAsync();
return fileInfo != null;
}
}

View File

@ -0,0 +1,9 @@
namespace BCards.Web.Services;
public interface IDocumentStorageService
{
Task<string> SaveDocumentAsync(byte[] documentBytes, string fileName, string contentType);
Task<byte[]?> GetDocumentAsync(string documentId);
Task<bool> DeleteDocumentAsync(string documentId);
Task<bool> DocumentExistsAsync(string documentId);
}

View File

@ -48,10 +48,12 @@ public class PlanConfiguration
public int MaxPages { get; set; } public int MaxPages { get; set; }
public int MaxLinks { get; set; } public int MaxLinks { get; set; }
public bool AllowPremiumThemes { get; set; } public bool AllowPremiumThemes { get; set; }
public bool AllowProductLinks { get; set; } public bool AllowProductLinks { get; set; }
public bool AllowAnalytics { get; set; } public bool AllowAnalytics { get; set; }
public bool? SpecialModeration { get; set; } public bool? SpecialModeration { get; set; }
public List<string> Features { get; set; } = new(); public bool AllowDocumentUpload { get; set; }
public int MaxDocuments { get; set; }
public List<string> Features { get; set; } = new();
public string Interval { get; set; } = "month"; public string Interval { get; set; } = "month";
public PlanType BasePlanType { get; set; } public PlanType BasePlanType { get; set; }
} }

View File

@ -58,8 +58,9 @@ public class LivePageService : ILivePageService
ProfileImageId = userPage.ProfileImageId, ProfileImageId = userPage.ProfileImageId,
BusinessType = userPage.BusinessType, BusinessType = userPage.BusinessType,
Theme = userPage.Theme, Theme = userPage.Theme,
Links = userPage.Links, Links = userPage.Links,
SeoSettings = userPage.SeoSettings, Documents = userPage.Documents,
SeoSettings = userPage.SeoSettings,
Language = userPage.Language, Language = userPage.Language,
Analytics = new LivePageAnalytics Analytics = new LivePageAnalytics
{ {
@ -115,4 +116,4 @@ public class LivePageService : ILivePageService
_logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); _logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex);
} }
} }
} }

View File

@ -372,17 +372,46 @@ public class PaymentService : IPaymentService
try try
{ {
var service = new SubscriptionService(); var service = new SubscriptionService();
var subscription = await service.GetAsync(subscriptionId);
if (refund) if (refund)
{ {
// Para reembolso completo, apenas cancela - reembolso deve ser feito manualmente via Stripe Dashboard // Processar reembolso automático - obter última charge e reembolsá-la
await service.CancelAsync(subscriptionId); try
{
var chargeService = new ChargeService();
var charges = await chargeService.ListAsync(new ChargeListOptions
{
Customer = subscription.CustomerId,
Limit = 1
});
if (charges.Data.Any())
{
var lastCharge = charges.Data.First();
if (lastCharge.Refunded == false && !string.IsNullOrEmpty(lastCharge.Id))
{
try
{
var refundService = new RefundService();
await refundService.CreateAsync(new RefundCreateOptions { Charge = lastCharge.Id });
}
catch (StripeException)
{
// Reembolso falhou mas continuaremos com o cancelamento
}
}
}
}
catch (StripeException)
{
// Se houver erro ao obter charge, ainda cancela a assinatura
}
} }
else
{ // Cancelar a assinatura
await service.CancelAsync(subscriptionId); await service.CancelAsync(subscriptionId);
}
// Atualizar subscription local // Atualizar subscription local
var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (localSubscription != null) if (localSubscription != null)
@ -391,7 +420,7 @@ public class PaymentService : IPaymentService
localSubscription.UpdatedAt = DateTime.UtcNow; localSubscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(localSubscription); await _subscriptionRepository.UpdateAsync(localSubscription);
} }
return true; return true;
} }
catch (StripeException) catch (StripeException)

View File

@ -37,68 +37,78 @@ public class PlanConfigurationService : IPlanConfigurationService
{ {
return planType switch return planType switch
{ {
PlanType.Trial => new PlanLimitations PlanType.Trial => new PlanLimitations
{ {
MaxLinks = 3, MaxLinks = 3,
AllowCustomThemes = false, AllowCustomThemes = false,
AllowAnalytics = false, AllowAnalytics = false,
AllowCustomDomain = false, AllowCustomDomain = false,
AllowMultipleDomains = false, AllowMultipleDomains = false,
PrioritySupport = false, PrioritySupport = false,
AllowProductLinks = false, AllowProductLinks = false,
MaxProductLinks = 0, MaxProductLinks = 0,
PlanType = "trial" PlanType = "trial",
}, AllowDocumentUpload = false,
PlanType.Basic => new PlanLimitations MaxDocuments = 0
{ },
MaxLinks = GetConfigValue(PlanType.Basic, "MaxLinks", 8), PlanType.Basic => new PlanLimitations
AllowCustomThemes = GetConfigValue(PlanType.Basic, "AllowPremiumThemes", false), {
AllowAnalytics = GetConfigValue(PlanType.Basic, "AllowAnalytics", true), MaxLinks = GetConfigValue(PlanType.Basic, "MaxLinks", 8),
AllowCustomDomain = true, AllowCustomThemes = GetConfigValue(PlanType.Basic, "AllowPremiumThemes", false),
AllowMultipleDomains = false, AllowAnalytics = GetConfigValue(PlanType.Basic, "AllowAnalytics", true),
PrioritySupport = false, AllowCustomDomain = true,
AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false), AllowMultipleDomains = false,
MaxProductLinks = 0, PrioritySupport = false,
PlanType = "basic" AllowProductLinks = GetConfigValue(PlanType.Basic, "AllowProductLinks", false),
}, MaxProductLinks = 0,
PlanType.Professional => new PlanLimitations PlanType = "basic",
{ AllowDocumentUpload = GetConfigValue(PlanType.Basic, "AllowDocumentUpload", false),
MaxLinks = GetConfigValue(PlanType.Professional, "MaxLinks", 20), MaxDocuments = GetConfigValue(PlanType.Basic, "MaxDocuments", 0)
AllowCustomThemes = GetConfigValue(PlanType.Professional, "AllowPremiumThemes", false), },
AllowAnalytics = GetConfigValue(PlanType.Professional, "AllowAnalytics", true), PlanType.Professional => new PlanLimitations
AllowCustomDomain = true, {
AllowMultipleDomains = false, MaxLinks = GetConfigValue(PlanType.Professional, "MaxLinks", 20),
PrioritySupport = false, AllowCustomThemes = GetConfigValue(PlanType.Professional, "AllowPremiumThemes", false),
AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false), AllowAnalytics = GetConfigValue(PlanType.Professional, "AllowAnalytics", true),
MaxProductLinks = 0, AllowCustomDomain = true,
PlanType = "professional" AllowMultipleDomains = false,
}, PrioritySupport = false,
PlanType.Premium => new PlanLimitations AllowProductLinks = GetConfigValue(PlanType.Professional, "AllowProductLinks", false),
{ MaxProductLinks = 0,
MaxLinks = GetConfigValue(PlanType.Premium, "MaxLinks", -1), PlanType = "professional",
AllowCustomThemes = GetConfigValue(PlanType.Premium, "AllowPremiumThemes", true), AllowDocumentUpload = GetConfigValue(PlanType.Professional, "AllowDocumentUpload", false),
AllowAnalytics = GetConfigValue(PlanType.Premium, "AllowAnalytics", true), MaxDocuments = GetConfigValue(PlanType.Professional, "MaxDocuments", 0)
AllowCustomDomain = true, },
AllowMultipleDomains = true, PlanType.Premium => new PlanLimitations
PrioritySupport = true, {
AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false), MaxLinks = GetConfigValue(PlanType.Premium, "MaxLinks", -1),
MaxProductLinks = 0, AllowCustomThemes = GetConfigValue(PlanType.Premium, "AllowPremiumThemes", true),
PlanType = "premium" AllowAnalytics = GetConfigValue(PlanType.Premium, "AllowAnalytics", true),
}, AllowCustomDomain = true,
PlanType.PremiumAffiliate => new PlanLimitations AllowMultipleDomains = true,
{ PrioritySupport = true,
MaxLinks = GetConfigValue(PlanType.PremiumAffiliate, "MaxLinks", -1), AllowProductLinks = GetConfigValue(PlanType.Premium, "AllowProductLinks", false),
AllowCustomThemes = GetConfigValue(PlanType.PremiumAffiliate, "AllowPremiumThemes", true), MaxProductLinks = 0,
AllowAnalytics = GetConfigValue(PlanType.PremiumAffiliate, "AllowAnalytics", true), PlanType = "premium",
AllowCustomDomain = true, AllowDocumentUpload = GetConfigValue(PlanType.Premium, "AllowDocumentUpload", true),
AllowMultipleDomains = true, MaxDocuments = GetConfigValue(PlanType.Premium, "MaxDocuments", 5)
PrioritySupport = true, },
AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true), PlanType.PremiumAffiliate => new PlanLimitations
MaxProductLinks = 10, {
PlanType = "premiumaffiliate" MaxLinks = GetConfigValue(PlanType.PremiumAffiliate, "MaxLinks", -1),
}, AllowCustomThemes = GetConfigValue(PlanType.PremiumAffiliate, "AllowPremiumThemes", true),
_ => new PlanLimitations { PlanType = "trial" } AllowAnalytics = GetConfigValue(PlanType.PremiumAffiliate, "AllowAnalytics", true),
}; AllowCustomDomain = true,
AllowMultipleDomains = true,
PrioritySupport = true,
AllowProductLinks = GetConfigValue(PlanType.PremiumAffiliate, "AllowProductLinks", true),
MaxProductLinks = 10,
PlanType = "premiumaffiliate",
AllowDocumentUpload = GetConfigValue(PlanType.PremiumAffiliate, "AllowDocumentUpload", true),
MaxDocuments = GetConfigValue(PlanType.PremiumAffiliate, "MaxDocuments", 10)
},
_ => new PlanLimitations { PlanType = "trial", AllowDocumentUpload = false, MaxDocuments = 0 }
};
} }
public string GetPriceId(PlanType planType, bool yearly = false) public string GetPriceId(PlanType planType, bool yearly = false)
@ -225,4 +235,4 @@ public class PlanConfigurationService : IPlanConfigurationService
return defaultValue; return defaultValue;
} }
} }

View File

@ -178,9 +178,9 @@ public class TrialExpirationService : BackgroundService
Para continuar usando sua página de links, escolha um de nossos planos: Para continuar usando sua página de links, escolha um de nossos planos:
Básico - R$ 9,90/mês Básico - R$ 12,90/mês
Profissional - R$ 24,90/mês Profissional - R$ 25,90/mês
Premium - R$ 29,90/mês Premium - R$ 29,90/mês
Acesse: {GetUpgradeUrl()} Acesse: {GetUpgradeUrl()}
@ -204,9 +204,9 @@ public class TrialExpirationService : BackgroundService
Para reativar sua página, escolha um de nossos planos: Para reativar sua página, escolha um de nossos planos:
Básico - R$ 9,90/mês - 5 links, analytics básicos Básico - R$ 12,90/mês - 5 links, analytics básicos
Profissional - R$ 24,90/mês - 15 links, todos os temas, analytics avançados Profissional - R$ 25,90/mês - 15 links, todos os temas, analytics avançados
Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos Premium - R$ 29,90/mês - Links ilimitados, temas premium, analytics completos, upload de PDFs
Seus dados estão seguros e serão restaurados assim que você escolher um plano. Seus dados estão seguros e serão restaurados assim que você escolher um plano.
@ -268,4 +268,4 @@ public class TrialExpirationService : BackgroundService
_logger.LogError(ex, "Error processing permanent deletions"); _logger.LogError(ex, "Error processing permanent deletions");
} }
} }
} }

View File

@ -43,6 +43,7 @@ public class ManagePageViewModel
public string KawaiUrl { get; set; } = string.Empty; public string KawaiUrl { get; set; } = string.Empty;
public List<ManageLinkViewModel> Links { get; set; } = new(); public List<ManageLinkViewModel> Links { get; set; } = new();
public List<ManageDocumentViewModel> Documents { get; set; } = new();
// Profile image fields // Profile image fields
public string? ProfileImageId { get; set; } public string? ProfileImageId { get; set; }
@ -55,6 +56,9 @@ public class ManagePageViewModel
// Plan limitations // Plan limitations
public int MaxLinksAllowed { get; set; } = 3; public int MaxLinksAllowed { get; set; } = 3;
public bool AllowProductLinks { get; set; } = false; public bool AllowProductLinks { get; set; } = false;
public int MaxDocumentsAllowed { get; set; } = 0;
public bool AllowDocumentUpload { get; set; } = false;
public string DocumentUploadPlansDisplay { get; set; } = "planos com suporte a documentos";
public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower());
/// <summary> /// <summary>
@ -101,6 +105,28 @@ public class ManageLinkViewModel
public DateTime? ProductDataCachedAt { get; set; } public DateTime? ProductDataCachedAt { get; set; }
} }
public class ManageDocumentViewModel
{
// Campos opcionais - preenchidos pelo model binding ou pelo controller
public string? Id { get; set; }
public string? DocumentId { get; set; }
public string? FileName { get; set; }
[Required(ErrorMessage = "Título é obrigatório")]
[StringLength(120, ErrorMessage = "Título deve ter no máximo 120 caracteres")]
public string Title { get; set; } = string.Empty;
[StringLength(300, ErrorMessage = "Descrição deve ter no máximo 300 caracteres")]
public string Description { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTime? UploadedAt { get; set; }
public IFormFile? DocumentFile { get; set; }
public bool MarkForRemoval { get; set; }
public bool ReplaceExisting => DocumentFile != null && !string.IsNullOrEmpty(DocumentId);
}
public class DashboardViewModel public class DashboardViewModel
{ {
public User CurrentUser { get; set; } = new(); public User CurrentUser { get; set; } = new();
@ -186,4 +212,4 @@ public class DowngradeCriteria
public string MaxLinksDisplay => MaxLinksPerPage == -1 ? "Ilimitado" : MaxLinksPerPage.ToString(); public string MaxLinksDisplay => MaxLinksPerPage == -1 ? "Ilimitado" : MaxLinksPerPage.ToString();
public string SelectionCriteria { get; set; } = "Páginas mais antigas têm prioridade"; public string SelectionCriteria { get; set; } = "Páginas mais antigas têm prioridade";
public string LinksCriteria { get; set; } = "Páginas com muitos links são automaticamente suspensas"; public string LinksCriteria { get; set; } = "Páginas com muitos links são automaticamente suspensas";
} }

View File

@ -29,7 +29,7 @@
<div class="progress-bar" role="progressbar" style="width: 25%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div> <div class="progress-bar" role="progressbar" style="width: 25%" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
<div class="d-flex justify-content-between mt-2"> <div class="d-flex justify-content-between mt-2">
<small class="text-muted">Passo 1 de 4</small> <small class="text-muted">Passo 1 de 5</small>
<small class="text-muted">Informações Básicas</small> <small class="text-muted">Informações Básicas</small>
</div> </div>
</div> </div>
@ -224,17 +224,158 @@
Próximo <i class="fas fa-arrow-right ms-1"></i> Próximo <i class="fas fa-arrow-right ms-1"></i>
</button> </button>
</div> </div>
</div>
</div>
</div>
<!-- Passo 3: Documentos PDF (Premium) -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingDocuments">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDocuments" aria-expanded="false" aria-controls="collapseDocuments">
<i class="fas fa-file-pdf me-2"></i>
Passo 3: Documentos PDF (Premium)
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span>
</button>
</h2>
<div id="collapseDocuments" class="accordion-collapse collapse" aria-labelledby="headingDocuments" data-bs-parent="#pageWizard">
<div class="accordion-body">
@if (Model.AllowDocumentUpload)
{
<p class="text-muted mb-3">Anexe PDFs com apresentações, catálogos ou materiais exclusivos para quem acessar sua página Premium.</p>
@if (Model.MaxDocumentsAllowed > 0)
{
<div class="alert alert-light border-start border-3 border-primary py-2 small">
<i class="fas fa-info-circle me-2 text-primary"></i>
Você pode anexar até <strong>@Model.MaxDocumentsAllowed</strong> documento(s) no seu plano atual.
</div>
}
<div id="documentsContainer">
@if (Model.Documents != null && Model.Documents.Count > 0)
{
for (var i = 0; i < Model.Documents.Count; i++)
{
<div class="document-input-group border rounded p-3 mb-3" data-document="@i">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h6 class="mb-1">Documento @(i + 1)</h6>
@if (Model.Documents[i].UploadedAt.HasValue)
{
<small class="text-muted">Atualizado em @Model.Documents[i].UploadedAt.Value.ToLocalTime().ToString("dd/MM/yyyy HH:mm")</small>
}
</div>
<div class="btn-group">
@if (!string.IsNullOrEmpty(Model.Documents[i].DocumentId))
{
<a class="btn btn-sm btn-outline-primary" href="/api/document/@Model.Documents[i].DocumentId" target="_blank">
<i class="fas fa-file-pdf me-1"></i> Ver PDF
</a>
}
<button type="button" class="btn btn-sm btn-outline-danger remove-document-btn">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Título <span class="text-danger">*</span></label>
<input asp-for="Documents[i].Title" class="form-control" placeholder="Ex: Apresentação de Serviços">
<span asp-validation-for="Documents[i].Title" class="text-danger"></span>
</div>
<div class="mb-3">
<label class="form-label">Descrição (opcional)</label>
<textarea asp-for="Documents[i].Description" class="form-control" rows="2" placeholder="Resumo do conteúdo"></textarea>
<span asp-validation-for="Documents[i].Description" class="text-danger"></span>
</div>
<div class="mb-3">
<label class="form-label">Arquivo PDF</label>
@if (!string.IsNullOrEmpty(Model.Documents[i].DocumentId))
{
<div class="bg-light border rounded p-3 mb-2 document-file-info">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>@Model.Documents[i].FileName</strong>
<div class="small text-muted">@((Model.Documents[i].FileSize / 1024.0).ToString("0.#")) KB</div>
</div>
<span class="badge bg-primary-subtle text-primary">PDF</span>
</div>
</div>
<input asp-for="Documents[i].DocumentFile" class="form-control" type="file" accept="application/pdf">
<small class="form-text text-muted">Envie outro PDF para substituir o arquivo atual (máx. 10MB).</small>
}
else
{
<input asp-for="Documents[i].DocumentFile" class="form-control" type="file" accept="application/pdf">
<small class="form-text text-muted">Envie um arquivo PDF (máx. 10MB).</small>
}
<span asp-validation-for="Documents[i].DocumentFile" class="text-danger"></span>
</div>
<input asp-for="Documents[i].Id" type="hidden">
<input asp-for="Documents[i].DocumentId" type="hidden">
<input asp-for="Documents[i].FileName" type="hidden">
<input asp-for="Documents[i].FileSize" type="hidden">
<input asp-for="Documents[i].UploadedAt" type="hidden">
<input asp-for="Documents[i].MarkForRemoval" type="hidden">
</div>
}
<div class="alert alert-info" id="documentsEmptyState" style="display: none;">
<i class="fas fa-folder-open me-2"></i>Nenhum documento adicionado ainda.
</div>
}
else
{
<div class="alert alert-info" id="documentsEmptyState">
<i class="fas fa-folder-open me-2"></i>Nenhum documento adicionado ainda.
</div>
}
</div>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3">
<small class="text-muted">Os documentos são exibidos em ordem de cadastro. Utilize títulos claros para facilitar o acesso.</small>
<button type="button" class="btn btn-outline-primary" id="addDocumentBtn">
<i class="fas fa-plus me-2"></i>Adicionar Documento
</button>
</div>
}
else
{
<div class="alert alert-warning d-flex align-items-start">
<i class="fas fa-crown me-2 mt-1"></i>
<div>
Upload de PDFs disponível apenas nos planos <strong>@Model.DocumentUploadPlansDisplay</strong>.
<div class="mt-2">
<a asp-controller="Home" asp-action="Pricing" class="btn btn-warning btn-sm">
<i class="fas fa-arrow-up me-1"></i>Fazer upgrade agora
</a>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(2)">
<i class="fas fa-arrow-left me-1"></i> Anterior
</button>
<button type="button" class="btn btn-primary" onclick="nextStep(4)">
Próximo <i class="fas fa-arrow-right ms-1"></i>
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Passo 3: Links Principais --> <!-- Passo 4: Links Principais -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header" id="headingLinks"> <h2 class="accordion-header" id="headingLinks">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLinks" aria-expanded="false" aria-controls="collapseLinks"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLinks" aria-expanded="false" aria-controls="collapseLinks">
<i class="fas fa-link me-2"></i> <i class="fas fa-link me-2"></i>
Passo 3: Links Principais Passo 4: Links Principais
<span class="badge bg-success ms-auto me-3" id="step3Status" style="display: none;">✓</span> <span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span>
</button> </button>
</h2> </h2>
<div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard"> <div id="collapseLinks" class="accordion-collapse collapse" aria-labelledby="headingLinks" data-bs-parent="#pageWizard">
@ -371,10 +512,10 @@
</button> </button>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(2)"> <button type="button" class="btn btn-outline-secondary" onclick="previousStep(3)">
<i class="fas fa-arrow-left me-1"></i> Anterior <i class="fas fa-arrow-left me-1"></i> Anterior
</button> </button>
<button type="button" class="btn btn-primary" onclick="nextStep(4)"> <button type="button" class="btn btn-primary" onclick="nextStep(5)">
Próximo <i class="fas fa-arrow-right ms-1"></i> Próximo <i class="fas fa-arrow-right ms-1"></i>
</button> </button>
</div> </div>
@ -394,18 +535,18 @@
var twitterUrl = twitter !=null ? twitter.Url.Replace("https://x.com/","").Replace("https://twitter.com/","").Replace("https://www.twitter.com/","") : ""; var twitterUrl = twitter !=null ? twitter.Url.Replace("https://x.com/","").Replace("https://twitter.com/","").Replace("https://www.twitter.com/","") : "";
var whatsappUrl = whatsapp !=null ? whatsapp.Url.Replace("https://wa.me/","").Replace("whatsapp://","") : ""; var whatsappUrl = whatsapp !=null ? whatsapp.Url.Replace("https://wa.me/","").Replace("whatsapp://","") : "";
var instagramUrl = instagram !=null ? instagram.Url.Replace("https://instagram.com/","").Replace("https://www.instagram.com/","") : ""; var instagramUrl = instagram !=null ? instagram.Url.Replace("https://instagram.com/","").Replace("https://www.instagram.com/","") : "";
var tiktokUrl = tiktok !=null ? tiktok.Url.Replace("https://tiktok.com/@@","").Replace("https://www.tiktok.com/@@","").Replace("https://vm.tiktok.com/","") : ""; var tiktokUrl = tiktok !=null ? tiktok.Url.Replace("https://tiktok.com/@","").Replace("https://www.tiktok.com/@","").Replace("https://vm.tiktok.com/","") : "";
var pinterestUrl = pinterest !=null ? pinterest.Url.Replace("https://pinterest.com/","").Replace("https://www.pinterest.com/","").Replace("https://pin.it/","") : ""; var pinterestUrl = pinterest !=null ? pinterest.Url.Replace("https://pinterest.com/","").Replace("https://www.pinterest.com/","").Replace("https://pin.it/","") : "";
var discordUrl = discord !=null ? discord.Url.Replace("https://discord.gg/","").Replace("https://discord.com/invite/","") : ""; var discordUrl = discord !=null ? discord.Url.Replace("https://discord.gg/","").Replace("https://discord.com/invite/","") : "";
var kawaiUrl = kawai !=null ? kawai.Url.Replace("https://kawai.com/","").Replace("https://www.kawai.com/","") : ""; var kawaiUrl = kawai !=null ? kawai.Url.Replace("https://kawai.com/","").Replace("https://www.kawai.com/","") : "";
} }
<!-- Passo 4: Redes Sociais (Opcional) --> <!-- Passo 5: Redes Sociais (Opcional) -->
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header" id="headingSocial"> <h2 class="accordion-header" id="headingSocial">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseSocial" aria-expanded="false" aria-controls="collapseSocial"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseSocial" aria-expanded="false" aria-controls="collapseSocial">
<i class="fab fa-twitter me-2"></i> <i class="fab fa-twitter me-2"></i>
Passo 4: Redes Sociais (Opcional) Passo 5: Redes Sociais (Opcional)
<span class="badge bg-success ms-auto me-3" id="step4Status" style="display: none;">✓</span> <span class="badge bg-success ms-auto me-3" id="step5Status" style="display: none;">✓</span>
</button> </button>
</h2> </h2>
<div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard"> <div id="collapseSocial" class="accordion-collapse collapse" aria-labelledby="headingSocial" data-bs-parent="#pageWizard">
@ -432,9 +573,12 @@
<i class="fab fa-whatsapp text-success me-2"></i> <i class="fab fa-whatsapp text-success me-2"></i>
https://wa.me/ https://wa.me/
</span> </span>
<input type="text" class="form-control" id="whatsappNumber" placeholder="5511999999999"> <input type="text" class="form-control" id="whatsappNumber" placeholder="11987654321">
</div> </div>
<small class="form-text text-muted">Exemplo: 5511999999999 (código do país + DDD + número)</small> <small class="form-text text-muted">
<strong>Brasil:</strong> Digite apenas DDD + número (ex: 11987654321). O código 55 será adicionado automaticamente.<br>
<strong>Outros países:</strong> Digite o código do país + número completo.
</small>
<input asp-for="WhatsAppNumber" type="hidden" value="@(whatsappUrl ?? "")"> <input asp-for="WhatsAppNumber" type="hidden" value="@(whatsappUrl ?? "")">
<span asp-validation-for="WhatsAppNumber" class="text-danger"></span> <span asp-validation-for="WhatsAppNumber" class="text-danger"></span>
</div> </div>
@ -590,7 +734,7 @@
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary" onclick="previousStep(3)"> <button type="button" class="btn btn-outline-secondary" onclick="previousStep(4)">
<i class="fas fa-arrow-left me-1"></i> Anterior <i class="fas fa-arrow-left me-1"></i> Anterior
</button> </button>
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
@ -1039,7 +1183,9 @@
<script> <script>
let linkCount = @Model.Links.Count; let linkCount = @Model.Links.Count;
let documentCount = @(Model.Documents?.Count ?? 0);
let currentStep = 1; let currentStep = 1;
const totalSteps = 5;
$(document).ready(function() { $(document).ready(function() {
// Initialize social media fields // Initialize social media fields
@ -1050,6 +1196,9 @@
// Initialize URL input handlers // Initialize URL input handlers
initializeUrlInputs(); initializeUrlInputs();
// Initialize document handlers
initializeDocumentHandlers();
// Check for validation errors and show toast + open accordion // Check for validation errors and show toast + open accordion
checkValidationErrors(); checkValidationErrors();
@ -1058,14 +1207,22 @@
checkServerImageErrors(); checkServerImageErrors();
// Garantir que campos não marcados sejam string vazia ao submeter // Garantir que campos não marcados sejam string vazia ao submeter
$('form').on('submit', function() { $('form').on('submit', function(e) {
ensureUncheckedFieldsAreEmpty(); ensureUncheckedFieldsAreEmpty();
// Validar URLs de redes sociais antes de submeter
const validationResult = validateSocialMediaUrls();
if (!validationResult.valid) {
e.preventDefault();
alert('Erro de validação:\n\n' + validationResult.errors.join('\n'));
return false;
}
// Debug: Verificar quais campos de links estão sendo enviados // Debug: Verificar quais campos de links estão sendo enviados
console.log('=== DEBUG FORM SUBMISSION ==='); console.log('=== DEBUG FORM SUBMISSION ===');
const formData = new FormData(this); const formData = new FormData(this);
for (let [key, value] of formData.entries()) { for (let [key, value] of formData.entries()) {
if (key.includes('Links[')) { if (key.includes('Links[') || key.includes('Url') || key.includes('Number')) {
console.log(`${key}: ${value}`); console.log(`${key}: ${value}`);
} }
} }
@ -1097,7 +1254,7 @@
const maxlinks = @Model.MaxLinksAllowed; const maxlinks = @Model.MaxLinksAllowed;
// Links do modal são limitados pelo MaxLinksAllowed (redes sociais são separadas e opcionais) // Links do modal são limitados pelo MaxLinksAllowed (redes sociais são separadas e opcionais)
if (linkCount >= maxlinks) { if (linkCount >= maxlinks) {
alert('Você atingiu o limite de ' + maxlinks + ' links para seu plano atual. As redes sociais do Passo 4 não contam neste limite.'); alert('Você atingiu o limite de ' + maxlinks + ' links para seu plano atual. As redes sociais do Passo 5 não contam neste limite.');
return false; return false;
} }
}); });
@ -1189,7 +1346,7 @@
} }
function getStepName(step) { function getStepName(step) {
const names = ['', 'Basic', 'Theme', 'Links', 'Social']; const names = ['', 'Basic', 'Theme', 'Documents', 'Links', 'Social'];
return names[step]; return names[step];
} }
@ -1216,9 +1373,9 @@
} }
function updateProgress() { function updateProgress() {
const progress = (currentStep / 4) * 100; const progress = (currentStep / totalSteps) * 100;
$('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress); $('.progress-bar').css('width', `${progress}%`).attr('aria-valuenow', progress);
$('.progress').next().find('small').first().text(`Passo ${currentStep} de 4`); $('.progress').next().find('small').first().text(`Passo ${currentStep} de ${totalSteps}`);
} }
function generateSlug() { function generateSlug() {
@ -1359,6 +1516,167 @@
}); });
} }
function initializeDocumentHandlers() {
const $documentsContainer = $('#documentsContainer');
const $addDocumentBtn = $('#addDocumentBtn');
if (!$documentsContainer.length) {
return;
}
toggleDocumentEmptyState();
toggleDocumentAddButton();
if ($addDocumentBtn.length) {
$addDocumentBtn.on('click', function() {
addDocumentInput();
});
}
$(document).on('click', '.remove-document-btn', function() {
const $group = $(this).closest('.document-input-group');
const $markRemoval = $group.find('input[name$=".MarkForRemoval"]');
const hasExisting = $group.find('input[name$=".DocumentId"]').val();
if (hasExisting) {
$group.addClass('document-removed d-none');
if ($markRemoval.length) {
$markRemoval.val('true');
}
$group.find('input[type="file"]').val('');
} else {
$group.remove();
documentCount = Math.max(documentCount - 1, 0);
}
updateDocumentNumbers();
toggleDocumentEmptyState();
toggleDocumentAddButton();
});
$(document).on('change', '.document-input-group input[type="file"]', function(e) {
const file = e.target.files[0];
if (!file) {
return;
}
if (file.type !== 'application/pdf') {
alert('Envie um arquivo em PDF.');
$(this).val('');
return;
}
if (file.size > 10 * 1024 * 1024) {
alert('Arquivo muito grande. Tamanho máximo: 10MB.');
$(this).val('');
}
});
}
function toggleDocumentEmptyState() {
const $container = $('#documentsContainer');
const $emptyState = $('#documentsEmptyState');
if (!$container.length || !$emptyState.length) {
return;
}
const visibleDocs = $container.find('.document-input-group').not('.document-removed');
if (visibleDocs.length === 0) {
$emptyState.show();
} else {
$emptyState.hide();
}
}
function toggleDocumentAddButton() {
const $button = $('#addDocumentBtn');
if (!$button.length) {
return;
}
const maxDocs = @Model.MaxDocumentsAllowed;
if (maxDocs <= 0) {
$button.prop('disabled', false).removeAttr('title');
return;
}
const visibleDocs = $('#documentsContainer .document-input-group').not('.document-removed').length;
if (visibleDocs >= maxDocs) {
$button.prop('disabled', true).attr('title', 'Você atingiu o limite de documentos do seu plano.');
} else {
$button.prop('disabled', false).removeAttr('title');
}
}
function addDocumentInput() {
const $container = $('#documentsContainer');
if (!$container.length) {
return;
}
const $button = $('#addDocumentBtn');
if ($button.length && $button.prop('disabled')) {
return;
}
const existingIndexes = [];
$('input[name^="Documents["]').each(function() {
const match = $(this).attr('name').match(/Documents\[(\d+)\]/);
if (match) {
existingIndexes.push(parseInt(match[1]));
}
});
const nextIndex = existingIndexes.length > 0 ? Math.max(...existingIndexes) + 1 : 0;
const displayCount = $container.find('.document-input-group').length + 1;
const template = `
<div class="document-input-group border rounded p-3 mb-3" data-document="${nextIndex}">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h6 class="mb-1">Documento ${displayCount}</h6>
</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-danger remove-document-btn">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Título <span class="text-danger">*</span></label>
<input type="text" name="Documents[${nextIndex}].Title" class="form-control" placeholder="Ex: Apresentação de Serviços">
</div>
<div class="mb-3">
<label class="form-label">Descrição (opcional)</label>
<textarea name="Documents[${nextIndex}].Description" class="form-control" rows="2" placeholder="Resumo do conteúdo"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Arquivo PDF</label>
<input type="file" name="Documents[${nextIndex}].DocumentFile" class="form-control" accept="application/pdf">
<small class="form-text text-muted">Envie um arquivo PDF (máx. 10MB).</small>
</div>
<input type="hidden" name="Documents[${nextIndex}].Id" value="">
<input type="hidden" name="Documents[${nextIndex}].DocumentId" value="">
<input type="hidden" name="Documents[${nextIndex}].FileName" value="">
<input type="hidden" name="Documents[${nextIndex}].FileSize" value="0">
<input type="hidden" name="Documents[${nextIndex}].UploadedAt" value="">
<input type="hidden" name="Documents[${nextIndex}].MarkForRemoval" value="false">
</div>`;
$container.append(template);
documentCount++;
$('#documentsEmptyState').hide();
toggleDocumentEmptyState();
toggleDocumentAddButton();
}
function updateDocumentNumbers() {
$('#documentsContainer .document-input-group').not('.document-removed').each(function(index) {
$(this).attr('data-document', index);
$(this).find('h6').text('Documento ' + (index + 1));
});
}
function saveNormalLink() { function saveNormalLink() {
const title = $('#linkTitle').val().trim(); const title = $('#linkTitle').val().trim();
const url = $('#linkUrl').val().trim(); const url = $('#linkUrl').val().trim();
@ -1707,7 +2025,7 @@
{ checkbox: '#enableDiscord', hidden: 'input[name="DiscordUrl"]' }, { checkbox: '#enableDiscord', hidden: 'input[name="DiscordUrl"]' },
{ checkbox: '#enableKawai', hidden: 'input[name="KawaiUrl"]' } { checkbox: '#enableKawai', hidden: 'input[name="KawaiUrl"]' }
]; ];
socialFields.forEach(field => { socialFields.forEach(field => {
if (!$(field.checkbox).is(':checked')) { if (!$(field.checkbox).is(':checked')) {
$(field.hidden).val(' '); // Forçar espaço para campos não marcados $(field.hidden).val(' '); // Forçar espaço para campos não marcados
@ -1715,6 +2033,82 @@
}); });
} }
// Validar URLs de redes sociais antes de submeter o formulário
function validateSocialMediaUrls() {
const errors = [];
const userLang = navigator.language || navigator.userLanguage;
const isBrazil = userLang.startsWith('pt-BR') || userLang.startsWith('pt');
// Validar WhatsApp
if ($('#enableWhatsApp').is(':checked')) {
const whatsappValue = $('input[name="WhatsAppNumber"]').val().trim();
if (whatsappValue && whatsappValue !== ' ') {
const cleanNumber = whatsappValue.replace('https://wa.me/', '').replace(/\D/g, '');
if (isBrazil) {
// Brasil: deve ter 13 dígitos (55 + DDD + número)
if (cleanNumber.length !== 13 || !cleanNumber.startsWith('55')) {
errors.push('⚠️ WhatsApp: Número brasileiro deve ter 13 dígitos (55 + DDD + número).\nExemplo: 5511987654321');
}
} else {
// Outros países: validar entre 10 e 15 dígitos
if (cleanNumber.length < 10 || cleanNumber.length > 15) {
errors.push('⚠️ WhatsApp: Número deve ter entre 10 e 15 dígitos (incluindo código do país).');
}
}
}
}
// Validar Facebook
if ($('#enableFacebook').is(':checked')) {
const facebookValue = $('input[name="FacebookUrl"]').val().trim();
if (facebookValue && facebookValue !== ' ') {
const cleanUrl = facebookValue.replace('https://facebook.com/', '').replace('https://www.facebook.com/', '');
if (cleanUrl.length < 3) {
errors.push('⚠️ Facebook: Nome de usuário deve ter pelo menos 3 caracteres.');
}
}
}
// Validar Instagram
if ($('#enableInstagram').is(':checked')) {
const instagramValue = $('input[name="InstagramUrl"]').val().trim();
if (instagramValue && instagramValue !== ' ') {
const cleanUrl = instagramValue.replace('https://instagram.com/', '').replace('https://www.instagram.com/', '');
if (cleanUrl.length < 3) {
errors.push('⚠️ Instagram: Nome de usuário deve ter pelo menos 3 caracteres.');
}
}
}
// Validar Twitter
if ($('#enableTwitter').is(':checked')) {
const twitterValue = $('input[name="TwitterUrl"]').val().trim();
if (twitterValue && twitterValue !== ' ') {
const cleanUrl = twitterValue.replace('https://x.com/', '').replace('https://twitter.com/', '');
if (cleanUrl.length < 3) {
errors.push('⚠️ Twitter/X: Nome de usuário deve ter pelo menos 3 caracteres.');
}
}
}
// Validar TikTok
if ($('#enableTiktok').is(':checked')) {
const tiktokValue = $('input[name="TiktokUrl"]').val().trim();
if (tiktokValue && tiktokValue !== ' ') {
const cleanUrl = tiktokValue.replace('https://tiktok.com/@@', '').replace('https://www.tiktok.com/@@', '');
if (cleanUrl.length < 3) {
errors.push('⚠️ TikTok: Nome de usuário deve ter pelo menos 3 caracteres.');
}
}
}
return {
valid: errors.length === 0,
errors: errors
};
}
// Social Media Functions // Social Media Functions
function cleanSocialPrefix(value, socialType) { function cleanSocialPrefix(value, socialType) {
const prefixes = { const prefixes = {
@ -1732,10 +2126,16 @@
for (let prefix of typePrefixes) { for (let prefix of typePrefixes) {
if (value.startsWith(prefix)) { if (value.startsWith(prefix)) {
return value.replace(prefix, ''); value = value.replace(prefix, '');
break;
} }
} }
// TikTok: remover @@ do início (o prefixo já tem @@)
if (socialType === 'Tiktok' && value.startsWith('@@')) {
value = value.substring(1);
}
return value; return value;
} }
@ -1817,9 +2217,19 @@
if (isWhatsApp) { if (isWhatsApp) {
// WhatsApp: apenas números // WhatsApp: apenas números
value = value.replace(/\D/g, ''); value = value.replace(/\D/g, '');
// Auto-adicionar código +55 para números brasileiros (11 dígitos sem código país)
// Detecta cultura pt-BR para aplicar validação BR
const userLang = navigator.language || navigator.userLanguage;
const isBrazil = userLang.startsWith('pt-BR') || userLang.startsWith('pt');
if (isBrazil && value.length === 11 && !value.startsWith('55')) {
value = '55' + value;
}
$(this).val(value); $(this).val(value);
} }
// Atualizar campo hidden - SEMPRE string, nunca null // Atualizar campo hidden - SEMPRE string, nunca null
if (value) { if (value) {
hiddenField.val(prefix + value); hiddenField.val(prefix + value);
@ -1835,15 +2245,29 @@
function updateSocialFieldFeedback(input, value, isWhatsApp) { function updateSocialFieldFeedback(input, value, isWhatsApp) {
// Remover classes anteriores // Remover classes anteriores
input.removeClass('is-valid is-invalid'); input.removeClass('is-valid is-invalid');
if (!value) return; if (!value) return;
if (isWhatsApp) { if (isWhatsApp) {
// Validar WhatsApp (mínimo 10 dígitos) // Validar WhatsApp com detecção de idioma
if (value.length >= 10) { const userLang = navigator.language || navigator.userLanguage;
input.addClass('is-valid'); const isBrazil = userLang.startsWith('pt-BR') || userLang.startsWith('pt');
if (isBrazil) {
// Brasil: validar 13 dígitos (55 + DDD + número)
if (value.length === 13 && value.startsWith('55')) {
input.addClass('is-valid');
} else if (value.length >= 10) {
// Ainda está digitando ou formato incorreto
input.addClass('is-invalid');
}
} else { } else {
input.addClass('is-invalid'); // Outros países: validar mínimo 10 dígitos (genérico)
if (value.length >= 10 && value.length <= 15) {
input.addClass('is-valid');
} else if (value.length > 0) {
input.addClass('is-invalid');
}
} }
} else { } else {
// Validar username (mínimo 3 caracteres, sem espaços) // Validar username (mínimo 3 caracteres, sem espaços)

View File

@ -79,15 +79,15 @@
<div class="card-header bg-light text-center py-4"> <div class="card-header bg-light text-center py-4">
<h5 class="mb-0">Básico</h5> <h5 class="mb-0">Básico</h5>
<div class="mt-3"> <div class="mt-3">
<div class="pricing-monthly"> <div class="pricing-monthly">
<span class="display-5 fw-bold text-primary">R$ 5,90</span> <span class="display-5 fw-bold text-primary">R$ 12,90</span>
<span class="text-muted">/mês</span> <span class="text-muted">/mês</span>
</div> </div>
<div class="pricing-yearly d-none"> <div class="pricing-yearly d-none">
<span class="display-5 fw-bold text-primary">R$ 59,00</span> <span class="display-5 fw-bold text-primary">R$ 129,00</span>
<span class="text-muted">/ano</span> <span class="text-muted">/ano</span>
<br> <br>
<small class="text-success">Economize R$ 11,80 (2 meses grátis)</small> <small class="text-success">Economize R$ 25,80 (2 meses grátis)</small>
</div> </div>
</div> </div>
</div> </div>
@ -153,15 +153,15 @@
<div class="card-header bg-warning bg-opacity-10 text-center py-4"> <div class="card-header bg-warning bg-opacity-10 text-center py-4">
<h5 class="mb-0">Profissional</h5> <h5 class="mb-0">Profissional</h5>
<div class="mt-3"> <div class="mt-3">
<div class="pricing-monthly"> <div class="pricing-monthly">
<span class="display-5 fw-bold text-warning">R$ 12,90</span> <span class="display-5 fw-bold text-warning">R$ 25,90</span>
<span class="text-muted">/mês</span> <span class="text-muted">/mês</span>
</div> </div>
<div class="pricing-yearly d-none"> <div class="pricing-yearly d-none">
<span class="display-5 fw-bold text-warning">R$ 129,00</span> <span class="display-5 fw-bold text-warning">R$ 259,00</span>
<span class="text-muted">/ano</span> <span class="text-muted">/ano</span>
<br> <br>
<small class="text-success">Economize R$ 25,80 (2 meses grátis)</small> <small class="text-success">Economize R$ 51,80 (2 meses grátis)</small>
</div> </div>
</div> </div>
</div> </div>
@ -230,15 +230,15 @@
<div class="card-header bg-primary text-white text-center py-4"> <div class="card-header bg-primary text-white text-center py-4">
<h5 class="mb-0">Premium</h5> <h5 class="mb-0">Premium</h5>
<div class="mt-3"> <div class="mt-3">
<div class="pricing-monthly"> <div class="pricing-monthly">
<span class="display-5 fw-bold">R$ 19,90</span> <span class="display-5 fw-bold">R$ 29,90</span>
<span class="opacity-75">/mês</span> <span class="opacity-75">/mês</span>
</div> </div>
<div class="pricing-yearly d-none"> <div class="pricing-yearly d-none">
<span class="display-5 fw-bold">R$ 199,00</span> <span class="display-5 fw-bold">R$ 299,00</span>
<span class="opacity-75">/ano</span> <span class="opacity-75">/ano</span>
<br> <br>
<small class="text-light">Economize R$ 39,80 (2 meses grátis)</small> <small class="text-light">Economize R$ 59,80 (2 meses grátis)</small>
</div> </div>
</div> </div>
<small class="opacity-75">Melhor custo-benefício!</small> <small class="opacity-75">Melhor custo-benefício!</small>
@ -265,14 +265,18 @@
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
URL personalizada URL personalizada
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
Suporte prioritário Suporte prioritário
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-muted me-2">✗</i> <i class="text-success me-2">✓</i>
<span class="text-muted">Links de produto</span> Upload de PDFs (até 5 arquivos)
</li> </li>
<li class="mb-3">
<i class="text-muted me-2">✗</i>
<span class="text-muted">Links de produto</span>
</li>
</ul> </ul>
<div class="mt-3"> <div class="mt-3">
<small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small> <small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small>
@ -311,15 +315,15 @@
<div class="card-header bg-success text-white text-center py-4"> <div class="card-header bg-success text-white text-center py-4">
<h5 class="mb-0">Premium + Afiliados</h5> <h5 class="mb-0">Premium + Afiliados</h5>
<div class="mt-3"> <div class="mt-3">
<div class="pricing-monthly"> <div class="pricing-monthly">
<span class="display-5 fw-bold">R$ 29,90</span> <span class="display-5 fw-bold">R$ 34,90</span>
<span class="opacity-75">/mês</span> <span class="opacity-75">/mês</span>
</div> </div>
<div class="pricing-yearly d-none"> <div class="pricing-yearly d-none">
<span class="display-5 fw-bold">R$ 299,00</span> <span class="display-5 fw-bold">R$ 349,00</span>
<span class="opacity-75">/ano</span> <span class="opacity-75">/ano</span>
<br> <br>
<small class="text-light">Economize R$ 59,80 (2 meses grátis)</small> <small class="text-light">Economize R$ 69,80 (2 meses grátis)</small>
</div> </div>
</div> </div>
<small class="opacity-75">Para monetização!</small> <small class="opacity-75">Para monetização!</small>
@ -346,15 +350,19 @@
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
Moderação plus Moderação plus
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
Suporte prioritário Suporte prioritário
</li> </li>
<li class="mb-3"> <li class="mb-3">
<i class="text-success me-2">✓</i> <i class="text-success me-2">✓</i>
10 links afiliados 10 links afiliados
</li> </li>
</ul> <li class="mb-3">
<i class="text-success me-2">✓</i>
Upload de PDFs (até 10 arquivos)
</li>
</ul>
<div class="mt-3"> <div class="mt-3">
<small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small> <small class="text-muted">* 20 temas básicos + 20 temas premium exclusivos</small>
</div> </div>
@ -595,4 +603,4 @@ document.addEventListener('DOMContentLoaded', function() {
monthlyRadio.addEventListener('change', togglePricing); monthlyRadio.addEventListener('change', togglePricing);
yearlyRadio.addEventListener('change', togglePricing); yearlyRadio.addEventListener('change', togglePricing);
}); });
</script> </script>

View File

@ -0,0 +1,129 @@
@model BCards.Web.Areas.Support.Models.SupportOptions
<!-- Support FAB (Floating Action Button) -->
<div class="support-fab-container">
<button class="support-fab-trigger" id="supportFabTrigger" aria-label="Precisa de Ajuda?">
<i class="fas fa-question-circle"></i>
</button>
<div class="support-fab-menu" id="supportFabMenu" style="display: none;">
<div class="support-fab-header">
<h6>Precisa de Ajuda?</h6>
<p class="small text-muted mb-0">Plano: @Model.UserPlan</p>
</div>
@if (Model.CanAccessTelegram)
{
<a href="@Model.TelegramUrl" target="_blank" rel="noopener noreferrer" class="support-fab-option support-fab-telegram">
<div class="support-fab-option-icon">
<i class="fab fa-telegram"></i>
</div>
<div class="support-fab-option-content">
<strong>Falar no Telegram</strong>
<small>Suporte prioritário</small>
</div>
</a>
}
@if (Model.CanUseContactForm)
{
<a href="@Url.Action("ContactForm", "Support", new { area = "Support" })" class="support-fab-option support-fab-form">
<div class="support-fab-option-icon">
<i class="fas fa-envelope"></i>
</div>
<div class="support-fab-option-content">
<strong>Enviar Mensagem</strong>
<small>Formulário de contato</small>
</div>
</a>
}
@if (Model.CanRate)
{
<button type="button" class="support-fab-option support-fab-rating" data-bs-toggle="modal" data-bs-target="#ratingModal">
<div class="support-fab-option-icon">
<i class="fas fa-star"></i>
</div>
<div class="support-fab-option-content">
<strong>Avaliar Serviço</strong>
<small>Conte sua experiência</small>
</div>
</button>
}
@if (!Model.CanUseContactForm && !Model.CanAccessTelegram)
{
<div class="support-fab-upgrade">
<p class="small mb-2">
<i class="fas fa-lock text-warning"></i>
Faça upgrade para acessar mais opções de suporte!
</p>
<a href="@Url.Action("Index", "Payment", new { area = "" })" class="btn btn-sm btn-primary">Ver Planos</a>
</div>
}
</div>
</div>
<!-- Rating Modal -->
<div class="modal fade" id="ratingModal" tabindex="-1" aria-labelledby="ratingModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ratingModalLabel">Avalie nossa plataforma</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
</div>
<div class="modal-body">
<form id="ratingForm">
<div class="mb-3">
<label class="form-label">Sua avaliação: <span class="text-danger">*</span></label>
<div class="star-rating" id="starRating">
<i class="far fa-star" data-rating="1"></i>
<i class="far fa-star" data-rating="2"></i>
<i class="far fa-star" data-rating="3"></i>
<i class="far fa-star" data-rating="4"></i>
<i class="far fa-star" data-rating="5"></i>
</div>
<input type="hidden" id="ratingValue" name="ratingValue" required>
<div class="invalid-feedback d-block" id="ratingError" style="display: none !important;">
Por favor, selecione uma avaliação
</div>
</div>
<div class="mb-3">
<label for="ratingName" class="form-label">Nome (opcional)</label>
<input type="text" class="form-control" id="ratingName" name="name" maxlength="100" placeholder="Seu nome">
</div>
<div class="mb-3">
<label for="ratingEmail" class="form-label">Email (opcional)</label>
<input type="email" class="form-control" id="ratingEmail" name="email" placeholder="seu@email.com">
</div>
<div class="mb-3">
<label for="ratingComment" class="form-label">Comentário (opcional)</label>
<textarea class="form-control" id="ratingComment" name="comment" rows="3" maxlength="500" placeholder="Conte-nos sobre sua experiência..."></textarea>
<small class="form-text text-muted">
<span id="commentCounter">0</span>/500 caracteres
</small>
</div>
<div class="alert alert-success d-none" id="ratingSuccessAlert" role="alert">
<i class="fas fa-check-circle"></i> Avaliação enviada com sucesso! Obrigado pelo feedback.
</div>
<div class="alert alert-danger d-none" id="ratingErrorAlert" role="alert">
<i class="fas fa-exclamation-triangle"></i> <span id="ratingErrorMessage">Erro ao enviar avaliação. Tente novamente.</span>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary" id="submitRatingBtn">
<i class="fas fa-paper-plane"></i> Enviar Avaliação
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- CSS e JS serão carregados via _Layout.cshtml -->

View File

@ -36,10 +36,20 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/support-fab.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/rating.css" asp-append-version="true" />
<link rel="icon" type="image/x-icon" href="~/favicon.ico" /> <link rel="icon" type="image/x-icon" href="~/favicon.ico" />
@await RenderSectionAsync("Styles", required: false) @await RenderSectionAsync("Styles", required: false)
<script type="text/javascript">
(function(c,l,a,r,i,t,y){
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
})(window, document, "clarity", "script", "tzv881489n");
</script>
<!-- Estilos para menu ativo e barra de carregamento --> <!-- Estilos para menu ativo e barra de carregamento -->
<style> <style>
/* Barra de carregamento moderna */ /* Barra de carregamento moderna */
@ -150,12 +160,28 @@
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")" <a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="" asp-controller="Home" asp-action="Pricing"> asp-area="" asp-controller="Home" asp-action="Pricing">
Planos Planos
</a> </a>
</li> </li>
@* Menu Artigos - Condicional (só aparece se existir ao menos 1 artigo) *@
@{
var artigosPath = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), "Content", "Artigos");
var hasArtigos = System.IO.Directory.Exists(artigosPath) &&
System.IO.Directory.GetFiles(artigosPath, "*.pt-BR.md").Any();
}
@if (hasArtigos)
{
<li class="nav-item">
<a class="nav-link @(ViewBag.IsHomePage == true ? "text-white" : "text-dark")"
asp-area="Artigos" asp-controller="Artigos" asp-action="Index">
Artigos
</a>
</li>
}
@* Menu de Moderação via ViewComponent *@ @* Menu de Moderação via ViewComponent *@
@await Component.InvokeAsync("ModerationMenu") @await Component.InvokeAsync("ModerationMenu")
</ul> </ul>
@ -207,6 +233,9 @@
</div> </div>
</div> </div>
<!-- Support FAB (Floating Action Button) -->
@await Component.InvokeAsync("SupportFab")
</main> </main>
</div> </div>
@ -238,10 +267,10 @@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<a asp-controller="Admin" asp-action="Dashboard" class="btn btn-primary btn-sm">Comece Agora</a> <a asp-controller="Admin" asp-action="Dashboard" class="btn btn-primary btn-sm">Comece Agora</a>
} }
else else
{ {
<a asp-controller="Auth" asp-action="Login" class="btn btn-primary btn-sm">Comece Agora</a> <a asp-controller="Auth" asp-action="Login" class="btn btn-primary btn-sm">Comece Agora</a>
} }
</div> </div>
</div> </div>
@ -253,6 +282,8 @@
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/js/cookie-consent.js" asp-append-version="true"></script> <script src="~/js/cookie-consent.js" asp-append-version="true"></script>
<script src="~/js/email-handler.js" asp-append-version="true"></script> <script src="~/js/email-handler.js" asp-append-version="true"></script>
<script src="~/js/support-fab.js" asp-append-version="true"></script>
<script src="~/js/rating.js" asp-append-version="true"></script>
<!-- Scripts para menu ativo e barra de carregamento --> <!-- Scripts para menu ativo e barra de carregamento -->
<script> <script>

View File

@ -10,6 +10,84 @@
Layout = isPreview ? "_PreviewLayout" : "_UserPageLayout"; Layout = isPreview ? "_PreviewLayout" : "_UserPageLayout";
} }
@functions {
/// <summary>
/// Normaliza URLs de redes sociais para garantir que sempre tenham protocolo HTTPS
/// Corrige URLs que foram salvas sem prefixo HTTP(S)
/// </summary>
string NormalizeSocialUrl(string url, string icon)
{
if (string.IsNullOrEmpty(url)) return "#";
// Se já tem protocolo, retorna direto
if (url.StartsWith("http://") || url.StartsWith("https://"))
return url;
// WhatsApp - garantir prefixo wa.me
if (!string.IsNullOrEmpty(icon) && icon.Contains("whatsapp"))
{
// Remove qualquer prefixo parcial que possa existir
var cleanUrl = url.Replace("wa.me/", "").Replace("whatsapp://", "");
return $"https://wa.me/{cleanUrl}";
}
// Facebook
if (!string.IsNullOrEmpty(icon) && icon.Contains("facebook"))
{
var cleanUrl = url.Replace("facebook.com/", "").Replace("fb.com/", "");
return $"https://facebook.com/{cleanUrl}";
}
// Instagram
if (!string.IsNullOrEmpty(icon) && icon.Contains("instagram"))
{
var cleanUrl = url.Replace("instagram.com/", "").Replace("instagr.am/", "");
return $"https://instagram.com/{cleanUrl}";
}
// Twitter/X
if (!string.IsNullOrEmpty(icon) && (icon.Contains("twitter") || icon.Contains("x-twitter")))
{
var cleanUrl = url.Replace("x.com/", "").Replace("twitter.com/", "");
return $"https://x.com/{cleanUrl}";
}
// TikTok
if (!string.IsNullOrEmpty(icon) && icon.Contains("tiktok"))
{
var cleanUrl = url.Replace("tiktok.com/", "").Replace("tiktok.com/@", "");
// Se não tem @, adiciona
if (!cleanUrl.StartsWith("@"))
cleanUrl = "@" + cleanUrl;
return $"https://tiktok.com/{cleanUrl}";
}
// Pinterest
if (!string.IsNullOrEmpty(icon) && icon.Contains("pinterest"))
{
var cleanUrl = url.Replace("pinterest.com/", "").Replace("pin.it/", "");
return $"https://pinterest.com/{cleanUrl}";
}
// Discord
if (!string.IsNullOrEmpty(icon) && icon.Contains("discord"))
{
var cleanUrl = url.Replace("discord.gg/", "").Replace("discord.com/invite/", "");
return $"https://discord.gg/{cleanUrl}";
}
// Kawai ou qualquer outra rede não identificada
// Se contém domínio, apenas adiciona https://
if (url.Contains(".") || url.Contains("/"))
{
return $"https://{url.TrimStart('/')}";
}
// Fallback: assume que é uma URL completa que está faltando protocolo
return $"https://{url}";
}
}
@section Head { @section Head {
@if (isLivePage) @if (isLivePage)
{ {
@ -44,6 +122,25 @@
margin-top: 2rem; margin-top: 2rem;
} }
.documents-section .list-group-item {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: inherit;
transition: all 0.2s ease;
border-radius: 12px;
padding: 1rem;
}
.documents-section .list-group-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.documents-section .list-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.qrcode-toggle { .qrcode-toggle {
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
@ -214,7 +311,7 @@
(link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductDescription))); (link.Type == BCards.Web.Models.LinkType.Product && !string.IsNullOrEmpty(link.ProductDescription)));
<div class="universal-link" data-link-id="@i"> <div class="universal-link" data-link-id="@i">
<a href="@link.Url" <a href="@NormalizeSocialUrl(link.Url, link.Icon)"
class="universal-link-header" class="universal-link-header"
onclick="recordClick('@Model.Id', @i)" onclick="recordClick('@Model.Id', @i)"
target="_blank" target="_blank"
@ -309,7 +406,61 @@
} }
} }
} }
else
@* Documentos integrados no mesmo container de links *@
@if (Model.Documents?.Any() == true)
{
@for (int docIndex = 0; docIndex < Model.Documents.Count; docIndex++)
{
var document = Model.Documents[docIndex];
var hasDescription = !string.IsNullOrEmpty(document.Description);
var uniqueId = $"doc-{docIndex}";
<div class="universal-link" data-document-id="@uniqueId">
<a href="/api/document/@document.FileId"
class="universal-link-header"
target="_blank"
rel="noopener noreferrer">
<div class="universal-link-content">
<div class="link-icon">
<i class="fas fa-file-pdf"></i>
</div>
<div class="link-text-container">
<div class="link-title">@document.Title</div>
@if (hasDescription && document.Description.Length > 50)
{
<div class="link-subtitle">@(document.Description.Substring(0, 50))...</div>
}
</div>
</div>
@if (hasDescription)
{
<button class="expand-arrow"
type="button"
onclick="event.preventDefault(); event.stopPropagation(); toggleLinkDetails('@uniqueId')">
<i class="fas fa-chevron-down"></i>
</button>
}
</a>
@if (hasDescription)
{
<div class="universal-link-details" id="details-@uniqueId">
<div class="expanded-description">@document.Description</div>
<div class="expanded-action">
<i class="fas fa-external-link-alt"></i>
Clique no título acima para abrir o PDF
</div>
</div>
}
</div>
}
}
@if ((Model.Links?.Any(l => l.IsActive) != true) && (Model.Documents?.Any() != true))
{ {
<div class="text-muted"> <div class="text-muted">
<p>Nenhum link disponível no momento.</p> <p>Nenhum link disponível no momento.</p>
@ -392,7 +543,11 @@
function toggleLinkDetails(linkIndex) { function toggleLinkDetails(linkIndex) {
const currentDetails = document.getElementById('details-' + linkIndex); const currentDetails = document.getElementById('details-' + linkIndex);
const currentArrow = document.querySelector(`[data-link-id="${linkIndex}"] .expand-arrow`); // Suporta tanto data-link-id (links) quanto data-document-id (documentos)
let currentArrow = document.querySelector(`[data-link-id="${linkIndex}"] .expand-arrow`);
if (!currentArrow) {
currentArrow = document.querySelector(`[data-document-id="${linkIndex}"] .expand-arrow`);
}
if (!currentDetails || !currentArrow) return; if (!currentDetails || !currentArrow) return;
const isCurrentlyExpanded = currentDetails.classList.contains('show'); const isCurrentlyExpanded = currentDetails.classList.contains('show');

View File

@ -19,106 +19,122 @@
"Environment": "test" "Environment": "test"
}, },
"Plans": { "Plans": {
"Basic": { "Basic": {
"Name": "Básico", "Name": "Básico",
"PriceId": "price_1RycPaBMIadsOxJVKioZZofK", "PriceId": "price_1RycPaBMIadsOxJVKioZZofK",
"Price": 5.90, "Price": 12.90,
"MaxPages": 3, "MaxPages": 3,
"MaxLinks": 8, "MaxLinks": 8,
"AllowPremiumThemes": false, "AllowPremiumThemes": false,
"AllowProductLinks": false, "AllowProductLinks": false,
"AllowAnalytics": true, "AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ], "AllowDocumentUpload": false,
"Interval": "month" "MaxDocuments": 0,
}, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ],
"Professional": { "Interval": "month"
"Name": "Profissional", },
"PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj", "Professional": {
"Price": 12.90, "Name": "Profissional",
"MaxPages": 5, "PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj",
"MaxLinks": 20, "Price": 25.90,
"AllowPremiumThemes": false, "MaxPages": 5,
"AllowProductLinks": false, "MaxLinks": 20,
"AllowAnalytics": true, "AllowPremiumThemes": false,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ], "AllowProductLinks": false,
"Interval": "month" "AllowAnalytics": true,
}, "AllowDocumentUpload": false,
"Premium": { "MaxDocuments": 0,
"Name": "Premium", "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ],
"PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu", "Interval": "month"
"Price": 19.90, },
"MaxPages": 15, "Premium": {
"MaxLinks": -1, "Name": "Premium",
"AllowPremiumThemes": true, "PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu",
"AllowProductLinks": false, "Price": 29.90,
"AllowAnalytics": true, "MaxPages": 15,
"SpecialModeration": false, "MaxLinks": -1,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ], "AllowPremiumThemes": true,
"Interval": "month" "AllowProductLinks": false,
}, "AllowAnalytics": true,
"PremiumAffiliate": { "SpecialModeration": false,
"Name": "Premium+Afiliados", "AllowDocumentUpload": true,
"PriceId": "price_1RycTaBMIadsOxJVeDLseXQq", "MaxDocuments": 5,
"Price": 29.90, "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Upload de PDFs (até 5 arquivos)" ],
"MaxPages": 15, "Interval": "month"
"MaxLinks": -1, },
"AllowPremiumThemes": true, "PremiumAffiliate": {
"AllowProductLinks": true, "Name": "Premium+Afiliados",
"AllowAnalytics": true, "PriceId": "price_1RycTaBMIadsOxJVeDLseXQq",
"SpecialModeration": true, "Price": 34.90,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ], "MaxPages": 15,
"Interval": "month" "MaxLinks": -1,
}, "AllowPremiumThemes": true,
"BasicYearly": { "AllowProductLinks": true,
"Name": "Básico Anual", "AllowAnalytics": true,
"PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS", "SpecialModeration": true,
"Price": 59.00, "AllowDocumentUpload": true,
"MaxPages": 3, "MaxDocuments": 10,
"MaxLinks": 8, "Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Upload de PDFs (até 10 arquivos)" ],
"AllowPremiumThemes": false, "Interval": "month"
"AllowProductLinks": false, },
"AllowAnalytics": true, "BasicYearly": {
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ], "Name": "Básico Anual",
"Interval": "year" "PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS",
}, "Price": 129.00,
"ProfessionalYearly": { "MaxPages": 3,
"Name": "Profissional Anual", "MaxLinks": 8,
"PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm", "AllowPremiumThemes": false,
"Price": 129.00, "AllowProductLinks": false,
"MaxPages": 5, "AllowAnalytics": true,
"MaxLinks": 20, "AllowDocumentUpload": false,
"AllowPremiumThemes": false, "MaxDocuments": 0,
"AllowProductLinks": false, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ],
"AllowAnalytics": true, "Interval": "year"
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ], },
"Interval": "year" "ProfessionalYearly": {
}, "Name": "Profissional Anual",
"PremiumYearly": { "PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm",
"Name": "Premium Anual", "Price": 259.00,
"PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m", "MaxPages": 5,
"Price": 199.00, "MaxLinks": 20,
"MaxPages": 15, "AllowPremiumThemes": false,
"MaxLinks": -1, "AllowProductLinks": false,
"AllowPremiumThemes": true, "AllowAnalytics": true,
"AllowProductLinks": false, "AllowDocumentUpload": false,
"AllowAnalytics": true, "MaxDocuments": 0,
"SpecialModeration": false, "Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ],
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ], "Interval": "year"
"Interval": "year" },
}, "PremiumYearly": {
"PremiumAffiliateYearly": { "Name": "Premium Anual",
"Name": "Premium+Afiliados Anual", "PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m",
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1", "Price": 299.00,
"Price": 299.00, "MaxPages": 15,
"MaxPages": 15, "MaxLinks": -1,
"MaxLinks": -1, "AllowPremiumThemes": true,
"AllowPremiumThemes": true, "AllowProductLinks": false,
"AllowProductLinks": true, "AllowAnalytics": true,
"AllowAnalytics": true, "SpecialModeration": false,
"SpecialModeration": true, "AllowDocumentUpload": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ], "MaxDocuments": 5,
"Interval": "year" "Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Upload de PDFs (até 5 arquivos)", "Economize R$ 39,80 (2 meses grátis)" ],
} "Interval": "year"
},
"PremiumAffiliateYearly": {
"Name": "Premium+Afiliados Anual",
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1",
"Price": 349.00,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": true,
"AllowAnalytics": true,
"SpecialModeration": true,
"AllowDocumentUpload": true,
"MaxDocuments": 10,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Upload de PDFs (até 10 arquivos)", "Economize R$ 59,80 (2 meses grátis)" ],
"Interval": "year"
}
}, },
"MongoDb": { "MongoDb": {
"ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin", "ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin",
@ -153,5 +169,12 @@
"FromEmail": "ricardo.carneiro@jobmaker.com.br", "FromEmail": "ricardo.carneiro@jobmaker.com.br",
"FromName": "Ricardo Carneiro" "FromName": "Ricardo Carneiro"
}, },
"BaseUrl": "https://bcards.site" "BaseUrl": "https://bcards.site",
} "Support": {
"TelegramUrl": "https://t.me/jobmakerbr",
"FormspreeUrl": "https://formspree.io/f/xpwynqpj",
"EnableTelegramForPlans": [ "Premium", "PremiumAffiliate" ],
"EnableFormForPlans": [ "Basic", "Professional", "Premium", "PremiumAffiliate" ],
"EnableRatingForAllUsers": true
}
}

View File

@ -0,0 +1,190 @@
/* Rating System Styles for BCards */
/* Star Rating Component */
.star-rating {
display: inline-flex;
gap: 0.5rem;
font-size: 2.5rem;
user-select: none;
cursor: pointer;
}
.star-rating i {
cursor: pointer;
transition: all 0.2s ease;
color: #e0e0e0;
}
.star-rating i:hover {
transform: scale(1.2);
}
.star-rating i.fas {
color: #ffc107;
}
.star-rating i.text-warning {
color: #ffc107 !important;
}
/* Rating Modal Customizations */
#ratingModal .modal-content {
border-radius: 1rem;
border: none;
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.2);
}
#ratingModal .modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 1rem 1rem 0 0;
padding: 1.5rem;
border-bottom: none;
}
#ratingModal .modal-title {
font-weight: 600;
font-size: 1.25rem;
}
#ratingModal .btn-close {
filter: brightness(0) invert(1);
opacity: 0.8;
}
#ratingModal .btn-close:hover {
opacity: 1;
}
#ratingModal .modal-body {
padding: 2rem;
}
/* Form Styling */
#ratingForm .form-label {
font-weight: 500;
color: #495057;
margin-bottom: 0.5rem;
}
#ratingForm .form-control {
border-radius: 0.5rem;
border: 1px solid #dee2e6;
padding: 0.75rem 1rem;
transition: all 0.2s ease;
}
#ratingForm .form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
outline: none;
}
#ratingForm textarea.form-control {
resize: vertical;
min-height: 100px;
}
/* Submit Button */
#submitRatingBtn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 0.5rem;
padding: 0.875rem 1.5rem;
font-weight: 600;
transition: all 0.3s ease;
color: white;
}
#submitRatingBtn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(102, 126, 234, 0.3);
background: linear-gradient(135deg, #5568d3 0%, #6a3f8f 100%);
}
#submitRatingBtn:active:not(:disabled) {
transform: translateY(0);
}
#submitRatingBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Alert Messages */
#ratingSuccessAlert,
#ratingErrorAlert {
border-radius: 0.75rem;
padding: 1rem 1.25rem;
animation: slideIn 0.3s ease;
}
#ratingSuccessAlert {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
}
#ratingErrorAlert {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Character Counter */
.form-text {
font-size: 0.875rem;
}
#commentCounter {
font-weight: 600;
color: #667eea;
}
/* Invalid Feedback */
.invalid-feedback {
display: none;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #dc3545;
}
.invalid-feedback.d-block {
display: block !important;
}
/* Responsive Adjustments */
@media (max-width: 576px) {
.star-rating {
font-size: 2rem;
gap: 0.25rem;
}
#ratingModal .modal-body {
padding: 1.5rem;
}
#ratingModal .modal-header {
padding: 1rem;
}
#ratingModal .modal-title {
font-size: 1.1rem;
}
#submitRatingBtn {
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
}
}

View File

@ -0,0 +1,186 @@
/* Support FAB (Floating Action Button) Styles for BCards */
.support-fab-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1050;
}
.support-fab-trigger {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
font-size: 1.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.support-fab-trigger:hover {
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.support-fab-trigger:active {
transform: scale(0.95);
}
.support-fab-menu {
position: absolute;
bottom: 70px;
right: 0;
min-width: 280px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 1rem;
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.support-fab-header {
padding-bottom: 0.75rem;
border-bottom: 1px solid #e9ecef;
margin-bottom: 0.75rem;
}
.support-fab-header h6 {
margin: 0;
font-weight: 600;
color: #212529;
}
.support-fab-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
text-decoration: none;
color: #212529;
transition: all 0.2s ease;
border: none;
background: none;
width: 100%;
text-align: left;
cursor: pointer;
}
.support-fab-option:hover {
background: #f8f9fa;
transform: translateX(4px);
}
.support-fab-option-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.support-fab-telegram .support-fab-option-icon {
background: linear-gradient(135deg, #0088cc 0%, #005580 100%);
color: white;
}
.support-fab-form .support-fab-option-icon {
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
color: white;
}
.support-fab-rating .support-fab-option-icon {
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
color: white;
}
.support-fab-option-content {
flex: 1;
}
.support-fab-option-content strong {
display: block;
font-weight: 600;
font-size: 0.9rem;
}
.support-fab-option-content small {
display: block;
color: #6c757d;
font-size: 0.75rem;
}
.support-fab-upgrade {
padding: 0.75rem;
background: #fff3cd;
border-radius: 8px;
text-align: center;
margin-top: 0.5rem;
}
.support-fab-upgrade p {
color: #856404;
font-size: 0.85rem;
}
.support-fab-upgrade .btn-sm {
font-size: 0.8rem;
padding: 0.375rem 0.75rem;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.support-fab-container {
bottom: 1.5rem;
right: 1.5rem;
}
.support-fab-trigger {
width: 56px;
height: 56px;
font-size: 1.3rem;
}
.support-fab-menu {
min-width: 260px;
right: 0;
}
}
@media (max-width: 576px) {
.support-fab-container {
bottom: 1rem;
right: 1rem;
}
.support-fab-trigger {
width: 52px;
height: 52px;
font-size: 1.2rem;
}
.support-fab-menu {
min-width: calc(100vw - 3rem);
max-width: 280px;
}
}

View File

@ -0,0 +1,212 @@
// Rating System for BCards
(function() {
'use strict';
let selectedRating = 0;
// Initialize
function init() {
setupEventListeners();
}
function setupEventListeners() {
// Star rating clicks
const stars = document.querySelectorAll('#starRating i');
stars.forEach(star => {
star.addEventListener('click', handleStarClick);
star.addEventListener('mouseover', handleStarHover);
});
// Star rating container mouse leave
const starContainer = document.getElementById('starRating');
if (starContainer) {
starContainer.addEventListener('mouseleave', resetStarHover);
}
// Form submission
const ratingForm = document.getElementById('ratingForm');
if (ratingForm) {
ratingForm.addEventListener('submit', handleFormSubmit);
}
// Modal reset on close
const ratingModal = document.getElementById('ratingModal');
if (ratingModal) {
ratingModal.addEventListener('hidden.bs.modal', resetForm);
}
// Character counter for comment
const commentField = document.getElementById('ratingComment');
if (commentField) {
commentField.addEventListener('input', updateCharacterCounter);
}
}
function handleStarClick(e) {
const starValue = parseInt(e.currentTarget.getAttribute('data-rating'));
selectedRating = starValue;
document.getElementById('ratingValue').value = starValue;
updateStars(starValue, true);
// Hide error if was showing
const errorDiv = document.getElementById('ratingError');
if (errorDiv) {
errorDiv.style.display = 'none';
}
}
function handleStarHover(e) {
const starValue = parseInt(e.currentTarget.getAttribute('data-rating'));
updateStars(starValue, false);
}
function resetStarHover() {
updateStars(selectedRating, true);
}
function updateStars(rating, permanent) {
const stars = document.querySelectorAll('#starRating i');
stars.forEach(star => {
const starValue = parseInt(star.getAttribute('data-rating'));
if (starValue <= rating) {
star.classList.remove('far');
star.classList.add('fas', 'text-warning');
} else {
star.classList.remove('fas', 'text-warning');
star.classList.add('far');
}
});
}
function updateCharacterCounter() {
const commentField = document.getElementById('ratingComment');
const counter = document.getElementById('commentCounter');
if (commentField && counter) {
counter.textContent = commentField.value.length;
}
}
async function handleFormSubmit(e) {
e.preventDefault();
// Validate rating
if (selectedRating === 0) {
const errorDiv = document.getElementById('ratingError');
if (errorDiv) {
errorDiv.style.display = 'block';
}
return;
}
// Get form data
const formData = {
ratingValue: selectedRating,
name: document.getElementById('ratingName').value.trim(),
email: document.getElementById('ratingEmail').value.trim(),
comment: document.getElementById('ratingComment').value.trim()
};
// Get submit button
const submitButton = document.getElementById('submitRatingBtn');
const originalButtonHtml = submitButton.innerHTML;
// Disable submit button
submitButton.disabled = true;
submitButton.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Enviando...';
// Hide previous alerts
document.getElementById('ratingSuccessAlert').classList.add('d-none');
document.getElementById('ratingErrorAlert').classList.add('d-none');
try {
// Send to backend
const response = await fetch('/api/ratings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
// Show success message
document.getElementById('ratingSuccessAlert').classList.remove('d-none');
// Hide form temporarily
document.getElementById('ratingForm').querySelectorAll('.mb-3').forEach(el => {
el.style.display = 'none';
});
submitButton.style.display = 'none';
// Close modal after 3 seconds
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(document.getElementById('ratingModal'));
if (modal) {
modal.hide();
}
}, 3000);
} else {
throw new Error(data.message || 'Failed to submit rating');
}
} catch (error) {
console.error('Error submitting rating:', error);
// Show error message
const errorAlert = document.getElementById('ratingErrorAlert');
const errorMessage = document.getElementById('ratingErrorMessage');
errorMessage.textContent = error.message || 'Erro ao enviar avaliação. Por favor, tente novamente.';
errorAlert.classList.remove('d-none');
// Re-enable submit button
submitButton.disabled = false;
submitButton.innerHTML = originalButtonHtml;
}
}
function resetForm() {
// Reset stars
selectedRating = 0;
document.getElementById('ratingValue').value = '';
updateStars(0, true);
// Reset form fields
const ratingForm = document.getElementById('ratingForm');
if (ratingForm) {
ratingForm.reset();
}
// Show all form elements
document.getElementById('ratingForm').querySelectorAll('.mb-3').forEach(el => {
el.style.display = 'block';
});
// Hide alerts
document.getElementById('ratingSuccessAlert').classList.add('d-none');
document.getElementById('ratingErrorAlert').classList.add('d-none');
const errorDiv = document.getElementById('ratingError');
if (errorDiv) {
errorDiv.style.display = 'none';
}
// Re-enable submit button
const submitButton = document.getElementById('submitRatingBtn');
submitButton.disabled = false;
submitButton.innerHTML = '<i class="fas fa-paper-plane"></i> Enviar Avaliação';
submitButton.style.display = 'block';
// Reset character counter
const counter = document.getElementById('commentCounter');
if (counter) {
counter.textContent = '0';
}
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@ -0,0 +1,56 @@
// Support FAB (Floating Action Button) for BCards
(function () {
'use strict';
function initSupportFab() {
const fabTrigger = document.getElementById('supportFabTrigger');
const fabMenu = document.getElementById('supportFabMenu');
if (!fabTrigger || !fabMenu) {
return;
}
// Toggle menu on trigger click
fabTrigger.addEventListener('click', function () {
if (fabMenu.style.display === 'none' || fabMenu.style.display === '') {
fabMenu.style.display = 'block';
fabTrigger.setAttribute('aria-expanded', 'true');
} else {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
}
});
// Close menu when clicking outside
document.addEventListener('click', function (event) {
if (!fabTrigger.contains(event.target) && !fabMenu.contains(event.target)) {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
}
});
// Close menu on ESC key
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && fabMenu.style.display === 'block') {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
}
});
// Close menu when clicking menu options
const menuOptions = fabMenu.querySelectorAll('.support-fab-option');
menuOptions.forEach(function (option) {
option.addEventListener('click', function () {
fabMenu.style.display = 'none';
fabTrigger.setAttribute('aria-expanded', 'false');
});
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSupportFab);
} else {
initSupportFab();
}
})();