Compare commits

...

157 Commits

Author SHA1 Message Date
7c0d91c68e Merge pull request 'Release/ajustes-headers' (#23) from Release/ajustes-headers into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 11m3s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m8s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
Reviewed-on: https://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/23
2026-01-06 00:38:46 +00:00
98709256ea feat: headers
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 8s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 12m20s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 1m1s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 6s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 / Deployment Summary (pull_request) Successful in 0s
2025-12-30 17:26:46 -03:00
a7cbba5c38 Merge branch 'main' of https://git.carneiro.ddnsfree.com/ricardo/BCards 2025-12-30 17:25:20 -03:00
cf17fd8464 Merge pull request 'Release/ArtigosPDF' (#22) 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 40m2s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m13s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/22
2025-11-29 23:01:42 +00:00
4e97efc160 fix: XSS 2025-11-16 14:16:57 -03:00
175effd1be fix: ajustes de artigos
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 / Run Tests (pull_request) Successful in 3s
BCards Deployment Pipeline / Build and Push Image (pull_request) Has been skipped
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 / Deployment Summary (pull_request) Successful in 0s
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m13s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 9s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-11-14 23:15:55 -03:00
96cc089b60 fix: ajustes de certificado e artigos 2025-11-14 23:07:12 -03:00
d77af5c614 fix: whatsapp links 2025-11-07 17:32:19 -03:00
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
94c77fc867 fix: swarm novo ajuste
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 10m0s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m2s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-10-27 22:34:13 -03:00
e59698ee83 fix: settings claude
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m49s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m8s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-10-27 21:18:47 -03:00
e1c1f38a34 fix: checkout
Some checks failed
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Blocked by required conditions
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Blocked by required conditions
BCards Deployment Pipeline / Cleanup Old Resources (push) Blocked by required conditions
BCards Deployment Pipeline / Deployment Summary (push) Blocked by required conditions
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Has been cancelled
2025-10-27 21:18:21 -03:00
644dbf0974 fix: deploy no swarm
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m38s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Failing after 11s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-10-27 21:06:27 -03:00
32e6ec24e3 Merge pull request 'fix: detalhes para o Claude' (#20) from Release/versao1 into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 6m49s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m30s
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/20
2025-10-27 23:21:57 +00:00
8f750ea3ba fix: detalhes para o Claude
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 / Run Tests (pull_request) Successful in 2s
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 / PR Validation (pull_request) Successful in 0s
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 / Deployment Summary (pull_request) Successful in 0s
BCards Deployment Pipeline / Build and Push Image (push) Successful in 6m25s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 9s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-10-27 12:40:33 -03:00
d76435f98a Merge pull request 'Release/versao1' (#19) from Release/versao1 into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 20m12s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m21s
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/19
2025-10-26 01:56:25 +00:00
c32b4ef034 feat: novas redes sociais e qrcodes.
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 5s
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 0s
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
BCards Deployment Pipeline / Build and Push Image (push) Successful in 19m44s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 22s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-10-25 22:35:27 -03:00
5d70ba797a feat: ajustes para downgrade
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 11s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 11m22s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 1m42s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-10-05 21:57:50 -03:00
5834afc648 Merge pull request 'Release/versao1' (#18) from Release/versao1 into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m2s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m32s
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/18
2025-09-22 18:37:52 +00:00
Ricardo Carneiro
dc00ea97a9 fix: ajuste de cache no codigo
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
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 10m2s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 7s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-09-22 15:27:13 -03:00
Ricardo Carneiro
f20f136350 fix: ajuste de deploy para quantidade de replicas
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m46s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Successful in 59s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-22 12:10:23 -03:00
Ricardo Carneiro
34503936dd fix: ajustes variaveis release
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 10m17s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Failing after 2m40s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-22 11:46:57 -03:00
Ricardo Carneiro
cf9906bafe fix: envs de release
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m34s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Failing after 2m39s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-22 09:33:47 -03:00
Ricardo Carneiro
caefa20110 fix: build de release com arquivo não encontrado
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m24s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Failing after 5s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-22 09:22:50 -03:00
Ricardo Carneiro
ee4db7a910 fix: deploy de release
Some checks failed
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 6m48s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Failing after 0s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-22 08:05:53 -03:00
Ricardo Carneiro
a7e1677949 fix: builde de release
Some checks failed
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Blocked by required conditions
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Blocked by required conditions
BCards Deployment Pipeline / Cleanup Old Resources (push) Blocked by required conditions
BCards Deployment Pipeline / Deployment Summary (push) Blocked by required conditions
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Failing after 3h6m11s
2025-09-21 22:58:05 -03:00
Ricardo Carneiro
b0d164c8a9 fix: modo release
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been cancelled
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been cancelled
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been cancelled
BCards Deployment Pipeline / Build and Push Image (push) Has been cancelled
BCards Deployment Pipeline / Deployment Summary (push) Has been cancelled
2025-09-21 22:03:30 -03:00
Ricardo Carneiro
4c7c31cd60 fix: deploy pelo i5
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 10m0s
BCards Deployment Pipeline / Deploy to Release Swarm (ARM) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m29s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-21 17:31:15 -03:00
Ricardo Carneiro
dc75f4af54 fix: build no docker swarm local
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 9m59s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Failing after 0s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-21 16:42:54 -03:00
Ricardo Carneiro
3f4fed08d5 fix: login microsoft
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m32s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m33s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-21 14:35:10 -03:00
Ricardo Carneiro
6c1c6cb543 fix: data protection
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m42s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m28s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-21 13:45:49 -03:00
Ricardo Carneiro
b70fd7c23a fix: cabeçalhos cloudflare
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m44s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m1s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-19 08:25:02 -03:00
Ricardo Carneiro
28777d8437 fix: paginas de teste
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m7s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m0s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-18 18:21:51 -03:00
1d80b48a81 fix: corrige problema completo de :443 em OAuth redirect URLs
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m23s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m58s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
- Adiciona fix para Microsoft OAuth (mesmo que Google)
- Remove especificação explícita de porta 443 no HostString
- Resolve CORS errors causados por porta explícita nos redirect URIs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 14:45:37 -03:00
1caac8d7fb fix: Remove explicit port 443 from OAuth redirect URIs for Cloudflare compatibility
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m30s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m1s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
This fixes CORS errors when using Ajax requests after session timeout.
The issue was that redirect_uri included :443 port causing Cloudflare issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 14:04:28 -03:00
3d2ce1f8cf fix: Increase session timeout to 7 days and set SameSite=None for Cloudflare compatibility
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 15m22s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m54s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-18 12:32:42 -03:00
Ricardo Carneiro
e043c853b1 fix: logs para validação de imagens
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 5s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m9s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m58s
BCards Deployment Pipeline / Deploy to Test (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-17 23:48:59 -03:00
Ricardo Carneiro
7b0bc89f06 fix: Campo maior para digitação de endereço de localização
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m49s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m51s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-14 21:16:31 -03:00
Ricardo Carneiro
378bcf54b6 fix: ajustes de links
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m34s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m0s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-14 18:19:23 -03:00
Ricardo Carneiro
2eada5f44c fix: slug duplicado
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 16m8s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m25s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-14 13:16:50 -03:00
Ricardo Carneiro
4bad39ec85 fix: logar erro
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 16m11s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m23s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-14 01:05:51 -03:00
Ricardo Carneiro
f98dac9178 fix: token
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 15m52s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m20s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-14 00:34:53 -03:00
Ricardo Carneiro
04f406f6bc fix: preview token
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m55s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m19s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-09-13 23:32:09 -03:00
Ricardo Carneiro
930ce8dab3 fix: fluxo de paginas e preview
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 16m26s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m20s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-13 21:51:37 -03:00
Ricardo Carneiro
f28dc8daa8 feat: adicionar log no status das paginas para entender o que está acontecendo.
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 1s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m0s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m21s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-12 15:16:12 -03:00
Ricardo Carneiro
9406997316 fix: erros
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m0s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m25s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-12 13:52:13 -03:00
Ricardo Carneiro
aea64c0b8e fix: bug para excluir trial
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 8m1s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 3m48s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-12 08:44:05 -03:00
Ricardo Carneiro
55ad73b505 fix: erros de produção
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 8m19s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m19s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-09-12 00:28:45 -03:00
Ricardo Carneiro
ffac8a787b fix: ajuste de tamanho de imagem e toast de mensagens para ajustes 2025-09-10 18:42:50 -03:00
Ricardo Carneiro
0387df1994 fix: adicionando logs
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m50s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m26s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-10 09:53:29 -03:00
Ricardo Carneiro
77a20d3f37 fix: edição dos links removida
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m8s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m23s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 22:43:53 -03:00
Ricardo Carneiro
ed9fe4902b fix: casas decimais
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m29s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m25s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 22:10:18 -03:00
Ricardo Carneiro
07e4d16428 fix: exibir o plano certo na tela de gestão de assinatura
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m5s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m37s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 21:41:30 -03:00
Ricardo Carneiro
b12ffb0016 fix: https://
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m54s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m29s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 21:09:15 -03:00
Ricardo Carneiro
074c850c67 fix: favicon
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m27s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m28s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 20:13:32 -03:00
Ricardo Carneiro
30ca77a12b fix: nome do plano Afiliados+ Premium
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m18s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m23s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 19:46:18 -03:00
Ricardo Carneiro
9a850d239f fix: Ajustes de cache e planos
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m3s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m22s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 19:13:21 -03:00
Ricardo Carneiro
61453b65c9 fix: planos de pagamentos associados ao appSettings
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m57s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m39s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 18:51:48 -03:00
Ricardo Carneiro
ad8cc06fd6 fix: aumentar logs para obter mais dados
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 / Build and Push Image (push) Successful in 16m6s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m23s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-09 16:12:38 -03:00
Ricardo Carneiro
25f686eccd fix: update currentplan
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m55s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m30s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-08 22:13:33 -03:00
Ricardo Carneiro
90a1ee2bfb fix: exibição do plano
Some checks failed
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Blocked by required conditions
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Blocked by required conditions
BCards Deployment Pipeline / Cleanup Old Resources (push) Blocked by required conditions
BCards Deployment Pipeline / Deployment Summary (push) Blocked by required conditions
BCards Deployment Pipeline / Run Tests (push) Successful in 1s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Has been cancelled
2025-09-08 22:12:07 -03:00
Ricardo Carneiro
dde67df72f fix: adding traceid por rotina
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m6s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m33s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-08 21:37:05 -03:00
Ricardo Carneiro
fa422ef685 fix: forçar log no webhook e no program.cs
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m3s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m28s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-08 19:19:18 -03:00
Ricardo Carneiro
c825fa2736 fix: bypass do ssl no program.cs para o opensearch
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m52s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m31s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-08 18:33:31 -03:00
Ricardo Carneiro
56454bec90 fix: remover https
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been cancelled
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been cancelled
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been cancelled
BCards Deployment Pipeline / Deployment Summary (push) Has been cancelled
BCards Deployment Pipeline / Build and Push Image (push) Has been cancelled
2025-09-08 18:30:52 -03:00
Ricardo Carneiro
1978193777 feat: Modificar do Seq para o OpenSearch
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 15m0s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m20s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-08 17:57:57 -03:00
Ricardo Carneiro
ce1c1409de feat: logs de debug
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 10s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m26s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m21s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-08 08:57:36 -03:00
Ricardo Carneiro
32b83923dc fix: busca do usuário para criar assinatura
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m54s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m22s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-07 22:32:01 -03:00
Ricardo Carneiro
0f6ae71997 fix: create plan
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m28s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m19s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-07 21:39:36 -03:00
Ricardo Carneiro
79c254905a fix: create plan 2025-09-07 21:39:32 -03:00
Ricardo Carneiro
787fa63f68 fix: stripe settings
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 16m1s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m21s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-07 18:51:07 -03:00
Ricardo Carneiro
6e70202fce feat: reativar assinatura cancelada
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m47s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m12s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-07 17:00:50 -03:00
Ricardo Carneiro
004bf284b5 fix: ajustes de performance
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m38s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m15s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-09-07 16:11:18 -03:00
Ricardo Carneiro
5abb4b52c5 fix: ajuste no health check
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m20s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m22s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-09-07 01:07:44 -03:00
Ricardo Carneiro
a434ff56eb fix: health check quebrado
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 15m28s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m36s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-09-07 00:41:49 -03:00
Ricardo Carneiro
d700bd35a9 fix: ajustes de notificações e restrição de tamanho da imagem
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 7m58s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m39s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-09-05 17:49:26 -03:00
Ricardo Carneiro
3becbe67c3 fix: secret do Stripe
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m16s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m15s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 22:55:03 -03:00
Ricardo Carneiro
6042eb59e6 fix: log com delay temporario para logar pagamento
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m41s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m14s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 22:28:20 -03:00
Ricardo Carneiro
406c298afb fix: login muito tempo parado e mais logs do stripe
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m25s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m17s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 20:21:56 -03:00
Ricardo Carneiro
572f1ebf2e fix: webhook stripe teste
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m48s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m14s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 15:13:35 -03:00
Ricardo Carneiro
331b3de374 fix: performance optimizations
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m13s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m16s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 14:44:41 -03:00
Ricardo Carneiro
7cc8f46a1a fix: ajustes no dashboard
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m6s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m15s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
2025-08-31 02:34:39 -03:00
Ricardo Carneiro
6598dbdcdd fix: ajustar email para bcards.site 2025-08-31 01:50:19 -03:00
Ricardo Carneiro
ce705c51ec feat: politica de privacidade e rodapé
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m53s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m15s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 01:01:20 -03:00
Ricardo Carneiro
e6d46572d1 fix: menu celular perfeito
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m11s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m16s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-31 00:31:08 -03:00
Ricardo Carneiro
bd90f7b064 feat: script para deploy manual
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m4s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m15s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-30 22:39:37 -03:00
Ricardo Carneiro
019893c911 fix: force deploy css
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m0s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m15s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-30 20:52:07 -03:00
Ricardo Carneiro
0ee5af38cc fix: menu celular
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m33s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m17s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-28 20:26:40 -03:00
Ricardo Carneiro
d587992eda fix: espaço após quebra de linha
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m24s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m17s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-28 19:42:39 -03:00
Ricardo Carneiro
6f6a02ba3d fix: remover porta
Some checks failed
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Blocked by required conditions
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Blocked by required conditions
BCards Deployment Pipeline / Cleanup Old Resources (push) Blocked by required conditions
BCards Deployment Pipeline / Deployment Summary (push) Blocked by required conditions
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Has been cancelled
2025-08-28 19:27:44 -03:00
Ricardo Carneiro
4ab141436f fix: health
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m13s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m13s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-28 18:53:15 -03:00
Ricardo Carneiro
f0c93d83a8 fix: layout do menu
Some checks failed
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Blocked by required conditions
BCards Deployment Pipeline / Cleanup Old Resources (push) Blocked by required conditions
BCards Deployment Pipeline / Deployment Summary (push) Blocked by required conditions
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m14s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been cancelled
2025-08-28 18:36:44 -03:00
Ricardo Carneiro
f97fbc3367 fix: acessar host para permitir acesso ao seq
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 5s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m15s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m33s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-28 18:06:50 -03:00
Ricardo Carneiro
8dfcc991f3 fix: adicionar ads.txt
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 7m51s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m36s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-27 19:15:34 -03:00
Ricardo Carneiro
76357013d7 fix: ip do seq
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m38s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m14s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-24 21:33:02 -03:00
Ricardo Carneiro
90cc01d7cf feat: heath checks, seq e logs
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 1s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m39s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m17s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-24 20:00:53 -03:00
Ricardo Carneiro
2d901708b8 fix: erro de categoria e falta de uma pagina de erro. 2025-08-24 12:16:51 -03:00
46afbb22cd Merge pull request 'feat/plano-rodape' (#17) from feat/plano-rodape into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m3s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m6s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/17
2025-08-22 00:50:30 +00:00
Ricardo Carneiro
def712bffe fix: redirect_uri ms
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-21 21:50:04 -03:00
Ricardo Carneiro
e727ffdedc fix: program.cs 2025-08-21 21:29:00 -03:00
54fcbc23fa Merge pull request 'fix retorno do login microsoft' (#16) from feat/plano-rodape into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m4s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m5s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/16
2025-08-22 00:05:39 +00:00
Ricardo Carneiro
054149b26d fix retorno do login microsoft
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-21 21:01:52 -03:00
b6432d2701 Merge pull request 'fix: http para https. Ajustar novamente.' (#15) from feat/plano-rodape into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 1s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m9s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m8s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/15
2025-08-21 23:45:44 +00:00
Ricardo Carneiro
4f200845f5 fix: http para https. Ajustar novamente.
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 3s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-21 20:44:53 -03:00
ef1df0b065 Merge pull request 'feat/plano-rodape' (#14) from feat/plano-rodape into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m7s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 3m5s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/14
2025-08-21 21:20:22 +00:00
Ricardo Carneiro
31ed2b3e15 Merge branch 'feat/plano-rodape' of http://git.carneiro.ddnsfree.com/ricardo/BCards into feat/plano-rodape
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-21 18:19:42 -03:00
Ricardo Carneiro
cc6bd6299d fix: https prod 2025-08-21 18:19:12 -03:00
b58827b724 Merge pull request 'feat/plano-rodape' (#13) from feat/plano-rodape into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 7m11s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m8s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/13
2025-08-21 20:36:08 +00:00
f29a4a8453 Merge branch 'main' into feat/plano-rodape
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-21 20:35:55 +00:00
Ricardo Carneiro
a49b457a85 feat: build appSettings prod, dev, etc
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 4s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-21 17:23:48 -03:00
Ricardo Carneiro
58057bd033 feat: novo plano com links de afiliados 2025-08-21 00:17:13 -03:00
Ricardo Carneiro
aa2c864689 fix: ajustes de rodape e visualização 2025-08-20 22:56:36 -03:00
d74e16fbaf Merge pull request 'fix: conexão mongodb' (#12) from feat/live-preview into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m16s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m5s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/12
2025-08-18 03:09:32 +00:00
Ricardo Carneiro
116134b87a fix: conexão mongodb
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 4s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-18 00:09:03 -03:00
a7a5fa7c79 Merge pull request 'fix: porta' (#11) from feat/live-preview into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m12s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m8s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/11
2025-08-18 02:38:02 +00:00
Ricardo Carneiro
5d21374ae2 fix: porta
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 23:37:05 -03:00
9b2cfd0e10 Merge pull request 'fix: build om docker file na raiz' (#10) from feat/live-preview into main
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 14m34s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m3s
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/10
2025-08-18 01:31:24 +00:00
Ricardo Carneiro
d03a4c00ab fix: build om docker file na raiz
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 22:30:42 -03:00
8554b68369 Merge pull request 'fix: build' (#9) from feat/live-preview into main
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 16m36s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Failing after 2s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
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/9
2025-08-18 00:53:04 +00:00
Ricardo Carneiro
5f5e609172 fix: build
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 1s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 21:52:33 -03:00
06a078d260 Merge pull request 'feat/live-preview' (#8) from feat/live-preview into main
Some checks failed
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Failing after 6s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Has been skipped
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s
Reviewed-on: http://git.carneiro.ddnsfree.com/ricardo/BCards/pulls/8
2025-08-18 00:50:02 +00:00
Ricardo Carneiro
ec559e8115 fix: build
All checks were successful
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 2s
BCards Deployment Pipeline / PR Validation (pull_request) Successful in 0s
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 Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 21:49:13 -03:00
Ricardo Carneiro
38c991758c fix: actions
Some checks failed
BCards Deployment Pipeline / Run Tests (pull_request) Successful in 12s
BCards Deployment Pipeline / Build and Push Image (pull_request) Failing after 8s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (pull_request) Has been skipped
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (pull_request) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (pull_request) Has been skipped
BCards Deployment Pipeline / Deployment Summary (pull_request) Successful in 0s
2025-08-17 21:44:46 -03:00
Ricardo Carneiro
951b44f7eb feat: pipeline 2025-08-17 21:35:45 -03:00
Ricardo Carneiro
0d9a0988fe feat: aparencia dashboard 2025-08-17 20:50:40 -03:00
Ricardo Carneiro
ef4d189ef1 feat: botão fechar scroll de temas 2025-08-17 19:36:30 -03:00
Ricardo Carneiro
ca98001299 fix: muitos temas! 2025-08-17 19:01:30 -03:00
Ricardo Carneiro
d32cc18044 Merge branch 'feat/live-preview' of http://git.carneiro.ddnsfree.com/ricardo/BCards into feat/live-preview 2025-08-17 15:46:09 -03:00
Ricardo Carneiro
c824e9da1c feat: imagens!!!! Agora tenho uma imagem no topo! 2025-08-17 15:45:59 -03:00
Ricardo Carneiro
9e7ea6ed9a feat: ajuste dos contadores 2025-08-17 00:29:17 -03:00
Ricardo Carneiro
5fc7eb5ad3 feat: ajustes de callback do stripe e atualização do stripe.net 2025-08-16 23:13:00 -03:00
Ricardo Carneiro
c6129a1c63 feat: links sociais opcionais 2025-08-16 18:55:17 -03:00
Ricardo Carneiro
2449a617ca feat: trial and pay 2025-08-16 17:10:45 -03:00
Ricardo Carneiro
038422c255 fix: sinal errado removido
All checks were successful
PR Validation for Release / Validate Pull Request (pull_request) Successful in 49s
PR Validation for Release / Ready for Merge (pull_request) Successful in 0s
2025-07-25 20:08:02 -03:00
Ricardo Carneiro
9604462289 fix: build após merge
All checks were successful
PR Validation for Release / Validate Pull Request (pull_request) Successful in 46s
PR Validation for Release / Ready for Merge (pull_request) Successful in 0s
2025-07-25 20:04:04 -03:00
Ricardo Carneiro
efb6a4e5d7 fix: novo publish stage no dockerfile.release
All checks were successful
PR Validation for Release / Validate Pull Request (pull_request) Successful in 53s
PR Validation for Release / Ready for Merge (pull_request) Successful in 0s
2025-07-25 19:55:45 -03:00
Ricardo Carneiro
660959bd4c fix: dockerbuild.release
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 53s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:49:52 -03:00
Ricardo Carneiro
c25eea5f03 fix: build para amd/x86
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 13s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:42:45 -03:00
Ricardo Carneiro
ceec4a2ef2 fix: buildx
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 14s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:40:51 -03:00
Ricardo Carneiro
764d8a62f6 fix: build multiplataforma
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 25s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:37:03 -03:00
Ricardo Carneiro
4500b927ad fix: pular testas se estiver nas variaveis
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 45s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-25 19:24:14 -03:00
Ricardo Carneiro
3b1a356e35 fix: pr validation
Some checks failed
PR Validation for Release / Validate Pull Request (pull_request) Failing after 27s
PR Validation for Release / Ready for Merge (pull_request) Has been skipped
2025-07-24 11:07:25 -03:00
Ricardo Carneiro
b9dcdcdc9e fix: pre-merge e build do docker 2025-07-23 15:42:58 -03:00
Ricardo Carneiro
6561bba061 fix: pipeline obtendo numero de versão incorreto pela branch. 2025-07-23 15:36:44 -03:00
Ricardo Carneiro
95bfe20049 fix: skip testes melhorado 2025-07-23 15:26:39 -03:00
Ricardo Carneiro
8de7da2d9c fix: skiptestes baseado em variavel 2025-07-23 15:19:10 -03:00
Ricardo Carneiro
0d9a4b96ae fix: appSettings de teste 2025-07-23 14:55:43 -03:00
Ricardo Carneiro
c76de82ff8 fix: build versão de release 2025-07-23 13:33:18 -03:00
Ricardo Carneiro
89026e3460 feat: release build 2025-07-22 23:19:17 -03:00
Ricardo Carneiro
8f677f62b8 fix: appSettings development 2025-07-22 14:49:07 -03:00
Ricardo Carneiro
c933510348 feat: Tela de gestão da assinatura 2025-07-14 23:21:25 -03:00
215 changed files with 32728 additions and 9698 deletions

View File

@ -17,7 +17,24 @@
"Bash(sudo rm:*)",
"Bash(rm:*)",
"Bash(curl:*)",
"Bash(docker-compose up:*)"
"Bash(docker-compose up:*)",
"Bash(dotnet build:*)",
"Bash(chmod:*)",
"Bash(mv:*)",
"Bash(dotnet nuget locals:*)",
"Bash(/mnt/c/vscode/vcart.me.novo/clean-build.sh:*)",
"Bash(sed:*)",
"Bash(./clean-build.sh:*)",
"Bash(git add:*)",
"Bash(scp:*)",
"Bash(ssh:*)",
"Bash(cat:*)",
"Bash(dig:*)",
"Bash(git commit:*)",
"Bash(netstat:*)",
"Bash(ss:*)",
"Bash(lsof:*)",
"Bash(dotnet run:*)"
]
},
"enableAllProjectMcpServers": false

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
bin/
obj/
.git/
.gitignore
*.md
README.md
tests/
docs/
.vs/
.vscode/
**/.DS_Store
**/Thumbs.db

View File

@ -0,0 +1,614 @@
name: BCards Deployment Pipeline
on:
push:
branches:
- main
- 'Release/*'
# PRs apenas validam, não fazem deploy
pull_request:
branches: [ main ]
types: [opened, synchronize, reopened]
env:
REGISTRY: registry.redecarneir.us
IMAGE_NAME: bcards
MONGODB_HOST: 192.168.0.100:27017
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Test info
run: |
echo "🧪 Executando testes para ${{ github.ref_name }}"
echo "🎯 Trigger: ${{ github.event_name }}"
# Verificar se deve pular testes
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
if [ "$SKIP_TESTS" == "true" ]; then
echo "⚠️ Testes PULADOS"
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
else
echo "✅ Executando testes"
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
fi
- name: Checkout code
if: env.TESTS_SKIPPED == 'false'
uses: actions/checkout@v4
- name: Setup .NET 8
if: env.TESTS_SKIPPED == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Cache dependencies
if: env.TESTS_SKIPPED == 'false'
uses: actions/cache@v3
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore dependencies
if: env.TESTS_SKIPPED == 'false'
run: dotnet restore
- name: Build solution
if: env.TESTS_SKIPPED == 'false'
run: dotnet build --no-restore --configuration Release
- name: Run unit tests
if: env.TESTS_SKIPPED == 'false'
run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage"
# Job específico para validação de PRs (sem deploy)
pr-validation:
name: PR Validation
runs-on: ubuntu-latest
needs: [test]
if: github.event_name == 'pull_request'
steps:
- name: PR Validation Summary
run: |
echo "✅ Pull Request Validation Summary"
echo "🎯 Target Branch: ${{ github.base_ref }}"
echo "📂 Source Branch: ${{ github.head_ref }}"
echo "🧪 Tests: ${{ needs.test.result }}"
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
echo "📝 Title: ${{ github.event.pull_request.title }}"
echo ""
echo "✨ PR está pronto para merge!"
build-and-push:
name: Build and Push Image
runs-on: [self-hosted, arm64, bcards]
needs: [test]
# Só faz build/push em push (não em PR)
if: github.event_name == 'push' && (needs.test.result == 'success' || needs.test.result == 'skipped')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/arm64
- name: Determine build settings
id: settings
run: |
BRANCH_NAME="${{ github.ref_name }}"
if [ "$BRANCH_NAME" = "main" ]; then
# Main = Produção (ARM64) - usando Dockerfile simples
echo "tag=latest" >> $GITHUB_OUTPUT
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
echo "environment=Production" >> $GITHUB_OUTPUT
echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT
echo "deploy_target=production" >> $GITHUB_OUTPUT
elif [[ "$BRANCH_NAME" == Release/* ]]; then
# Release = Swarm tests (Orange Pi arm64) - usando Dockerfile simples também
VERSION_RAW=${BRANCH_NAME#Release/}
# Only remove V/v if it's at the start and followed by a number (like v1.0.0)
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/')
[ -z "$VERSION" ] && VERSION="0.0.1"
echo "tag=$VERSION" >> $GITHUB_OUTPUT
echo "platform=linux/arm64" >> $GITHUB_OUTPUT
echo "environment=Testing" >> $GITHUB_OUTPUT
echo "dockerfile=Dockerfile" >> $GITHUB_OUTPUT
echo "deploy_target=testing" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
fi
COMMIT_SHA=${{ github.sha }}
SHORT_COMMIT=${COMMIT_SHA:0:7}
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
echo "📦 Tag: ${{ steps.settings.outputs.tag }}"
echo "🏗️ Platform: ${{ steps.settings.outputs.platform }}"
echo "🌍 Environment: ${{ steps.settings.outputs.environment }}"
echo "🎯 Target: ${{ steps.settings.outputs.deploy_target }}"
- name: Build and push image
run: |
echo "🏗️ Building image for ${{ steps.settings.outputs.deploy_target }}..."
# Debug das variáveis
echo "Platform: ${{ steps.settings.outputs.platform }}"
echo "Dockerfile: ${{ steps.settings.outputs.dockerfile }}"
echo "Tag: ${{ steps.settings.outputs.tag }}"
# Verificar se o Dockerfile existe
if [ ! -f "${{ steps.settings.outputs.dockerfile }}" ]; then
echo "❌ Dockerfile não encontrado: ${{ steps.settings.outputs.dockerfile }}"
echo "📂 Arquivos na raiz:"
ls -la
echo "📂 Arquivos em src/BCards.Web/:"
ls -la src/BCards.Web/ || echo "Diretório não existe"
exit 1
else
echo "✅ Dockerfile encontrado: ${{ steps.settings.outputs.dockerfile }}"
fi
# Build para a plataforma correta
if [ "${{ steps.settings.outputs.deploy_target }}" = "production" ]; then
# Build para produção (main branch) - Usa Configuration=Release (padrão)
docker buildx build \
--platform ${{ steps.settings.outputs.platform }} \
--file ${{ steps.settings.outputs.dockerfile }} \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \
--push \
--progress=plain \
.
else
# Build para staging (Release branches) - Usa Configuration=Testing para habilitar código de teste
docker buildx build \
--platform ${{ steps.settings.outputs.platform }} \
--file ${{ steps.settings.outputs.dockerfile }} \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.settings.outputs.tag }} \
--push \
--build-arg BUILD_CONFIGURATION=Testing \
--build-arg VERSION=${{ steps.settings.outputs.version || 'latest' }} \
--build-arg COMMIT=${{ steps.settings.outputs.commit }} \
--progress=plain \
.
fi
deploy-production:
name: Deploy to Production (ARM - OCI)
runs-on: ubuntu-latest
needs: [build-and-push]
if: github.ref_name == 'main'
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create Production Configuration
run: |
echo "🔧 Creating appsettings.Production.json with environment variables..."
# Cria o arquivo de configuração para produção
cat > appsettings.Production.json << 'CONFIG_EOF'
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"BCards": "Information",
"BCards.Web.Services.GridFSImageStorage": "Debug"
},
"Console": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Information"
}
},
"File": {
"Path": "/app/logs/bcards-{Date}.log",
"LogLevel": {
"Default": "Information"
}
}
},
"AllowedHosts": "*",
"Stripe": {
"PublishableKey": "${{ vars.STRIPE_PUBLISHABLE_KEY }}",
"SecretKey": "${{ secrets.STRIPE_SECRET_KEY }}",
"WebhookSecret": "${{ secrets.STRIPE_WEBHOOK_SECRET }}",
"Environment": "${{ vars.STRIPE_ENVIRONMENT || 'test' }}"
},
"Authentication": {
"Google": {
"ClientId": "${{ vars.GOOGLE_CLIENT_ID }}",
"ClientSecret": "${{ secrets.GOOGLE_CLIENT_SECRET }}"
},
"Microsoft": {
"ClientId": "${{ vars.MICROSOFT_CLIENT_ID }}",
"ClientSecret": "${{ secrets.MICROSOFT_CLIENT_SECRET }}"
}
},
"SendGrid": {
"ApiKey": "${{ secrets.SENDGRID_API_KEY }}",
"FromEmail": "${{ vars.SENDGRID_FROM_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}",
"FromName": "${{ vars.SENDGRID_FROM_NAME || 'Ricardo Carneiro' }}"
},
"Plans": {
"Basic": {
"Name": "Básico",
"PriceId": "price_1RycPaBMIadsOxJVKioZZofK",
"Price": 5.90,
"MaxPages": 3,
"MaxLinks": 8,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples" ],
"Interval": "month"
},
"Professional": {
"Name": "Profissional",
"PriceId": "price_1RycQmBMIadsOxJVGqjVMaOj",
"Price": 12.90,
"MaxPages": 5,
"MaxLinks": 20,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado" ],
"Interval": "month"
},
"Premium": {
"Name": "Premium",
"PriceId": "price_1RycRUBMIadsOxJVkxGOh3uu",
"Price": 19.90,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": false,
"AllowAnalytics": true,
"SpecialModeration": false,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados" ],
"Interval": "month"
},
"PremiumAffiliate": {
"Name": "Premium+Afiliados",
"PriceId": "price_1RycTaBMIadsOxJVeDLseXQq",
"Price": 29.90,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": true,
"AllowAnalytics": true,
"SpecialModeration": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados" ],
"Interval": "month"
},
"BasicYearly": {
"Name": "Básico Anual",
"PriceId": "price_1RycWgBMIadsOxJVGdtEeoMS",
"Price": 59.00,
"MaxPages": 3,
"MaxLinks": 8,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics simples", "Economize R$ 11,80 (2 meses grátis)" ],
"Interval": "year"
},
"ProfessionalYearly": {
"Name": "Profissional Anual",
"PriceId": "price_1RycXdBMIadsOxJV5cNX7dHm",
"Price": 129.00,
"MaxPages": 5,
"MaxLinks": 20,
"AllowPremiumThemes": false,
"AllowProductLinks": false,
"AllowAnalytics": true,
"Features": [ "URL Personalizada", "20 temas básicos", "Analytics avançado", "Economize R$ 25,80 (2 meses grátis)" ],
"Interval": "year"
},
"PremiumYearly": {
"Name": "Premium Anual",
"PriceId": "price_1RycYnBMIadsOxJVPdKmzy4m",
"Price": 199.00,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": false,
"AllowAnalytics": true,
"SpecialModeration": false,
"Features": [ "URL Personalizada", "40 temas (básicos + premium)", "Suporte prioritário", "Links ilimitados", "Economize R$ 39,80 (2 meses grátis)" ],
"Interval": "year"
},
"PremiumAffiliateYearly": {
"Name": "Premium+Afiliados Anual",
"PriceId": "price_1RycaEBMIadsOxJVEhsdB2Y1",
"Price": 299.00,
"MaxPages": 15,
"MaxLinks": -1,
"AllowPremiumThemes": true,
"AllowProductLinks": true,
"AllowAnalytics": true,
"SpecialModeration": true,
"Features": [ "Tudo do Premium", "Links de produto", "Moderação especial", "Até 10 links afiliados", "Economize R$ 59,80 (2 meses grátis)" ],
"Interval": "year"
}
},
"Moderation": {
"PriorityTimeframes": {
"Trial": "7.00:00:00",
"Basic": "7.00:00:00",
"Professional": "3.00:00:00",
"Premium": "1.00:00:00"
},
"MaxAttempts": 3,
"ModeratorEmail": "${{ vars.MODERATOR_EMAIL || 'ricardo.carneiro@jobmaker.com.br' }}",
"ModeratorEmails": [
"${{ vars.MODERATOR_EMAIL_1 || 'rrcgoncalves@gmail.com' }}",
"${{ vars.MODERATOR_EMAIL_2 || 'rirocarneiro@gmail.com' }}"
]
},
"MongoDb": {
"ConnectionString": "mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin",
"DatabaseName": "BCardsDB",
"MaxConnectionPoolSize": 100,
"ConnectTimeout": "30s",
"ServerSelectionTimeout": "30s",
"SocketTimeout": "30s"
},
"BaseUrl": "https://bcards.site",
"Environment": {
"Name": "Production",
"IsStagingEnvironment": false,
"AllowTestData": false,
"EnableDetailedErrors": false
},
"Performance": {
"EnableCaching": true,
"CacheExpirationMinutes": 30,
"EnableCompression": true,
"EnableResponseCaching": true
},
"Security": {
"EnableHttpsRedirection": true,
"EnableHsts": true,
"RequireHttpsMetadata": true
},
"HealthChecks": {
"Enabled": true,
"Endpoints": {
"Health": "/health",
"Ready": "/ready",
"Live": "/live"
},
"MongoDb": {
"Enabled": true,
"Timeout": "10s"
}
},
"Features": {
"EnablePreviewMode": true,
"EnableModerationWorkflow": true,
"EnableAnalytics": true,
"EnableFileUploads": true,
"MaxFileUploadSize": "5MB"
},
"Serilog": {
"OpenSearchUrl": "${{ vars.OPENSEARCH_URL || 'http://localhost:9201' }}"
}
}
CONFIG_EOF
echo "✅ Configuration file created!"
echo "🔍 File content (sensitive data masked):"
cat appsettings.Production.json | sed 's/"[^"]*_[0-9A-Za-z_]*"/"***MASKED***"/g'
- name: Deploy to Production Swarm
run: |
echo "🚀 Deploying to production Docker Swarm (ARM64)..."
# Configura SSH
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
# Adiciona hosts conhecidos
ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts
ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts
# Testa a chave SSH
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
# Upload configuration and stack file to swarm manager
echo "📤 Uploading files to Swarm manager..."
scp -o StrictHostKeyChecking=no appsettings.Production.json ubuntu@141.148.162.114:/tmp/
scp -o StrictHostKeyChecking=no deploy/docker-stack.yml ubuntu@141.148.162.114:/tmp/
# Deploy to Docker Swarm
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 << 'EOF'
set -e
echo "🔄 Updating Docker Swarm stack..."
# Update Docker config with new appsettings
echo "📝 Updating bcards-appsettings config..."
# Remove old config (will fail if in use, that's ok - swarm will use it until update)
docker config rm bcards-appsettings 2>/dev/null || echo "Config in use or doesn't exist, will create new one"
# Create new config with timestamp to force update
CONFIG_NAME="bcards-appsettings-$(date +%s)"
docker config create ${CONFIG_NAME} /tmp/appsettings.Production.json
# Update stack file to use new config name
sed "s/bcards-appsettings/${CONFIG_NAME}/g" /tmp/docker-stack.yml > /tmp/docker-stack-updated.yml
echo "🐳 Deploying stack to Swarm (rolling update, zero downtime)..."
docker stack deploy -c /tmp/docker-stack-updated.yml bcards --with-registry-auth
echo "⏳ Waiting for service to update..."
sleep 10
# Show service status
docker service ls --filter name=bcards_bcards-app
docker service ps bcards_bcards-app --no-trunc --filter "desired-state=running" | head -10
echo "🧹 Cleaning up standalone containers if they exist..."
docker stop bcards-prod 2>/dev/null || echo "No standalone container to stop"
docker rm bcards-prod 2>/dev/null || echo "No standalone container to remove"
# Clean up temp files
rm -f /tmp/appsettings.Production.json /tmp/docker-stack.yml /tmp/docker-stack-updated.yml
echo "✅ Swarm stack updated successfully!"
EOF
- name: Health Check Production
run: |
echo "🏥 Verificando saúde dos servidores de produção..."
sleep 30
# Verifica Servidor 1
echo "Verificando Servidor 1 (ARM)..."
ssh -o StrictHostKeyChecking=no ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 1 pode não estar respondendo"'
# Verifica Servidor 2
echo "Verificando Servidor 2 (ARM)..."
ssh -o StrictHostKeyChecking=no ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "⚠️ Servidor 2 pode não estar respondendo"'
deploy-test:
name: Deploy to Release Swarm (ARM)
runs-on: [self-hosted, arm64, bcards]
needs: [build-and-push]
if: startsWith(github.ref_name, 'Release/')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version
id: version
run: |
BRANCH_NAME="${{ github.ref_name }}"
VERSION_RAW=${BRANCH_NAME#Release/}
# Only remove V/v if it's at the start and followed by a number (like v1.0.0)
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]\([0-9]\)/\1/')
[ -z "$VERSION" ] && VERSION="0.0.1"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "📦 Deploying version: $VERSION"
- name: Prepare release stack manifest
run: |
mkdir -p artifacts
BCARDS_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
# Replace ${BCARDS_IMAGE} with actual image name using sed
sed "s|\${BCARDS_IMAGE}|${BCARDS_IMAGE}|g" deploy/docker-stack.release.yml > artifacts/docker-stack.release.yml
echo "🔧 Generated manifest with image: ${BCARDS_IMAGE}"
echo "📄 Manifest content:"
head -10 artifacts/docker-stack.release.yml
- name: Deploy to release swarm
run: |
echo "🚀 Deploying release stack to Orange Pi swarm..."
docker stack deploy -c artifacts/docker-stack.release.yml bcards-release
- name: Await release service readiness
run: |
echo "⏳ Aguardando serviço bcards-release estabilizar..."
ATTEMPTS=30
while [ $ATTEMPTS -gt 0 ]; do
REPLICAS=$(docker service ls --filter name=bcards-release_bcards-release --format '{{.Replicas}}')
if [ "$REPLICAS" = "1/1" ]; then
echo "✅ Serviço com $REPLICAS réplica"
break
fi
echo "Atual: ${REPLICAS:-N/A}; aguardando..."
sleep 5
ATTEMPTS=$((ATTEMPTS-1))
done
if [ "$REPLICAS" != "1/1" ]; then
echo "❌ Serviço não atingiu 1/1 réplica"
docker service ps bcards-release_bcards-release
exit 1
fi
docker service ps bcards-release_bcards-release
cleanup:
name: Cleanup Old Resources
runs-on: ubuntu-latest
needs: [deploy-production, deploy-test]
if: always() && (needs.deploy-production.result == 'success' || needs.deploy-test.result == 'success')
steps:
- name: Cleanup containers and images
run: |
echo "🧹 Limpando recursos antigos..."
if [ "${{ github.ref_name }}" = "main" ]; then
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H 141.148.162.114 >> ~/.ssh/known_hosts
ssh-keyscan -H 129.146.116.218 >> ~/.ssh/known_hosts
ssh-add ~/.ssh/id_rsa 2>/dev/null || echo "SSH key loaded"
for server in 141.148.162.114 129.146.116.218; do
echo "🧹 Limpando servidor $server..."
ssh -o StrictHostKeyChecking=no ubuntu@$server << 'EOF'
docker container prune -f
docker image prune -f
docker network prune -f
EOF
done
else
echo " Release branch: limpeza remota ignorada (Swarm gerencia recursos)."
fi
echo "✅ Limpeza concluída!"
deployment-summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [deploy-production, deploy-test]
if: always()
steps:
- name: Summary
run: |
echo "📋 DEPLOYMENT SUMMARY"
echo "===================="
echo "🎯 Branch: ${{ github.ref_name }}"
echo "🔑 Commit: ${{ github.sha }}"
echo "🏗️ Registry: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
if [ "${{ github.ref_name }}" = "main" ]; then
echo "🌍 Environment: Production (Swarm ARM)"
echo "🖥️ Servers: 141.148.162.114, 129.146.116.218"
echo "📦 Tag: latest"
echo "🔗 Status: ${{ needs.deploy-production.result }}"
else
echo "🌍 Environment: Release (Swarm ARM)"
echo "🖥️ Servers: 141.148.162.114, 129.146.116.218"
echo "📦 Branch Tag: ${{ github.ref_name }}"
echo "🔗 Status: ${{ needs.deploy-test.result }}"
fi
echo "===================="
echo "✅ Pipeline completed!"

View File

@ -0,0 +1,111 @@
name: PR Validation for Release
on:
pull_request:
branches:
- 'Release/*'
types: [opened, synchronize, reopened, ready_for_review]
env:
REGISTRY: registry.redecarneir.us
IMAGE_NAME: bcards
MONGODB_HOST: 192.168.0.100:27017
jobs:
validate-pr:
name: Validate Pull Request
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: PR Info
run: |
echo "🔍 Validando PR #${{ github.event.number }}"
echo "📂 Source: ${{ github.head_ref }}"
echo "🎯 Target: ${{ github.base_ref }}"
echo "👤 Author: ${{ github.event.pull_request.user.login }}"
echo "📝 Title: ${{ github.event.pull_request.title }}"
- name: Checkout PR code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Run tests
if: ${{ vars.SKIP_TESTS_PR != 'true' }}
run: |
echo "🧪 Executando testes no PR"
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
if [ "$SKIP_TESTS" == "true" ]; then
echo "⚠️ Testes PULADOS"
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
else
echo "✅ Executando testes"
dotnet test --no-build --configuration Release --verbosity normal
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
fi
- name: Build Docker image (test only)
run: |
echo "🐳 Testando build da imagem Docker..."
# Extrair versão da branch de destino
TARGET_BRANCH="${{ github.base_ref }}"
VERSION_RAW=${TARGET_BRANCH#Release/}
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
COMMIT_SHA=${{ github.event.pull_request.head.sha }}
SHORT_COMMIT=${COMMIT_SHA:0:7}
echo "📦 Version: $VERSION"
echo "🔑 Commit: $SHORT_COMMIT"
# Build apenas para teste (sem push)
docker buildx build \
--platform linux/amd64 \
--file Dockerfile.release \
--build-arg VERSION=$VERSION \
--build-arg COMMIT=$SHORT_COMMIT \
--tag $REGISTRY/$IMAGE_NAME:pr-${{ github.event.number }}-$SHORT_COMMIT \
--output type=docker \
.
- name: Security scan (opcional)
run: |
echo "🔒 Executando verificações de segurança..."
# Adicione suas verificações de segurança aqui
- name: PR Status Summary
run: |
echo "✅ Pull Request Validation Summary"
echo "🎯 Target Branch: ${{ github.base_ref }}"
echo "📂 Source Branch: ${{ github.head_ref }}"
echo "🧪 Tests: ${{ vars.SKIP_TESTS_PR == 'true' && 'SKIPPED' || 'PASSED' }}"
echo "🐳 Docker Build: PASSED"
echo "🔒 Security Scan: PASSED"
echo ""
echo "✨ PR está pronto para merge!"
# Job que só executa se a validação passou
ready-for-merge:
name: Ready for Merge
runs-on: ubuntu-latest
needs: [validate-pr]
if: success()
steps:
- name: Merge readiness
run: |
echo "🎉 Pull Request #${{ github.event.number }} passou em todas as validações!"
echo "✅ Pode ser feito o merge com segurança"

View File

@ -0,0 +1,120 @@
name: Release Deployment Pipeline
on:
push:
branches:
- 'Release/*'
env:
REGISTRY: registry.redecarneir.us
IMAGE_NAME: bcards
MONGODB_HOST: 192.168.0.100:27017
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Check if tests should run
run: |
# Prioridade: manual input > variável do repo
SKIP_TESTS="${{ github.event.inputs.skip_tests || vars.SKIP_TESTS }}"
if [ "$SKIP_TESTS" == "true" ]; then
echo "⚠️ Testes PULADOS"
echo "TESTS_SKIPPED=true" >> $GITHUB_ENV
else
echo "✅ Executando testes"
echo "TESTS_SKIPPED=false" >> $GITHUB_ENV
fi
echo "🎯 Trigger: ${{ github.event_name }}"
echo "📂 Branch: ${{ github.ref_name }}"
- name: Checkout code
if: env.TESTS_SKIPPED == 'false'
uses: actions/checkout@v4
- name: Setup .NET 8
if: env.TESTS_SKIPPED == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
if: env.TESTS_SKIPPED == 'false'
run: dotnet restore
- name: Build solution
if: env.TESTS_SKIPPED == 'false'
run: dotnet build --no-restore --configuration Release
- name: Run unit tests
if: env.TESTS_SKIPPED == 'false'
run: dotnet test --no-build --configuration Release --verbosity normal
build-and-deploy:
name: Build and Deploy
runs-on: ubuntu-latest
needs: [test]
if: always() && (needs.test.result == 'success' || needs.test.result == 'failure')
steps:
- name: Deployment info
run: |
echo "🚀 Iniciando deployment para ${{ github.ref_name }}"
echo "🧪 Tests: ${{ vars.SKIP_TESTS == 'true' && 'SKIPPED' || 'EXECUTED' }}"
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Extract version info
id: version
run: |
BRANCH_NAME="${{ github.ref_name }}"
VERSION_RAW=${BRANCH_NAME#Release/}
VERSION=$(echo "$VERSION_RAW" | sed 's/^[Vv]//')
if [ -z "$VERSION" ]; then
VERSION="0.0.1"
fi
COMMIT_SHA=${{ github.sha }}
SHORT_COMMIT=${COMMIT_SHA:0:7}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "commit=$SHORT_COMMIT" >> $GITHUB_OUTPUT
echo "tag=$VERSION-$SHORT_COMMIT" >> $GITHUB_OUTPUT
echo "📦 Version: $VERSION"
echo "🔑 Commit: $SHORT_COMMIT"
echo "🏷️ Tag: $VERSION-$SHORT_COMMIT"
- name: Build and push multi-arch image
run: |
echo "🏗️ Building multi-arch image..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.release \
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }} \
--tag $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.version }}-latest \
--tag $REGISTRY/$IMAGE_NAME:release-latest \
--push \
--build-arg VERSION=${{ steps.version.outputs.version }} \
--build-arg COMMIT=${{ steps.version.outputs.commit }} \
--progress=plain
# Resto do deployment...
- name: Deploy notification
run: |
echo "✅ Deployment concluído!"
echo "📦 Image: $REGISTRY/$IMAGE_NAME:${{ steps.version.outputs.tag }}"
echo "🎯 Trigger: ${{ github.event_name }}"
echo "📂 Branch: ${{ github.ref_name }}"

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

@ -4,24 +4,50 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scripts", "Scripts", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
ProjectSection(SolutionItems) = preProject
scripts\deploy-release.sh = scripts\deploy-release.sh
scripts\init-mongo.js = scripts\init-mongo.js
scripts\test-mongodb-connection.sh = scripts\test-mongodb-connection.sh
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{3F3DEEDF-9E0A-434D-8130-1FBAC43FD1F7}"
ProjectSection(SolutionItems) = preProject
.gitea\workflows\deploy-bcards.yml = .gitea\workflows\deploy-bcards.yml
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
Conexoes.txt = Conexoes.txt
README.md = README.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
Testing|Any CPU = Testing|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.Build.0 = Testing|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.Build.0 = Testing|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3DA87F09-8B78-450D-9EF8-A0C0E02F0E04}
EndGlobalSection
EndGlobal

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

@ -10,10 +10,11 @@ BCards is a professional LinkTree clone built in ASP.NET Core MVC, targeting Bra
### Build & Run
```bash
# Restore dependencies
dotnet restore
# Quick clean build (RECOMMENDED after VS 2022 updates)
./clean-build.sh
# Build solution
# Manual process:
dotnet restore
dotnet build
# Run development server
@ -26,6 +27,23 @@ docker-compose up -d
# Access: http://localhost:8080
```
### 🚨 Known Issues After VS 2022 Updates
**Problem**: After VS 2022 updates, build cache gets corrupted causing:
- OAuth login failures (especially Google in Edge browser)
- Need for constant clean/rebuild cycles
- NuGet package resolution errors
**Solution**: Use the automated cleanup script:
```bash
./clean-build.sh
```
**Google OAuth Edge Issue**: If Google login shows "browser not secure" error:
1. Clear browser data for localhost:49178 and accounts.google.com
2. Test in incognito/private mode
3. Use Vivaldi or Chrome (Edge has known compatibility issues)
### Testing
```bash
# Run all tests

4
Conexoes.txt Normal file
View File

@ -0,0 +1,4 @@
bcards
ssh ubuntu@141.148.162.114
convert-it
ssh ubuntu@129.146.116.218

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!

41
Dockerfile Normal file
View File

@ -0,0 +1,41 @@
# Dockerfile - Production build (similar to QRRapido structure)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
RUN dotnet restore "src/BCards.Web/BCards.Web.csproj"
COPY . .
WORKDIR "/src/src/BCards.Web"
RUN dotnet build "BCards.Web.csproj" -c ${BUILD_CONFIGURATION} -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "BCards.Web.csproj" -c ${BUILD_CONFIGURATION} -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
# Create uploads directory
RUN mkdir -p /app/uploads && chmod 755 /app/uploads
# Install dependencies for image processing
RUN apt-get update && apt-get install -y \
libgdiplus \
curl \
&& rm -rf /var/lib/apt/lists/*
# Environment variables
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_RUNNING_IN_CONTAINER=true
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "BCards.Web.dll"]

178
GITEA-VARIABLES-SETUP.md Normal file
View File

@ -0,0 +1,178 @@
# 🔧 **CONFIGURAÇÃO DE VARIÁVEIS NO GITEA**
## 📋 **VISÃO GERAL**
Agora o deploy é **100% automatizado**! O YAML cria o `appsettings.Production.json` dinamicamente usando variáveis do Gitea.
---
## ⚙️ **COMO CONFIGURAR NO GITEA**
### **1. Acessar Configurações**
```bash
1. Vá para: https://seu-gitea.com/seu-usuario/vcart.me.novo
2. Clique em: Settings (Configurações)
3. Na sidebar: Secrets and variables → Actions
```
### **2. Criar SECRETS (dados sensíveis)**
Clique em **"New repository secret"** para cada um:
```bash
# STRIPE (obrigatório)
STRIPE_SECRET_KEY = sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO
STRIPE_WEBHOOK_SECRET = whsec_SEU_WEBHOOK_SECRET_AQUI
# OAUTH (obrigatório)
GOOGLE_CLIENT_SECRET = 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
MICROSOFT_CLIENT_SECRET = T0.8Q~an.51iW1H0DVjL2i1bmSK_qTgVQOuEmapK
# SENDGRID (obrigatório)
SENDGRID_API_KEY = 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
```
### **3. Criar VARIABLES (dados não-sensíveis)**
Clique em **"New repository variable"** para cada um:
```bash
# STRIPE
STRIPE_PUBLISHABLE_KEY = pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS
STRIPE_ENVIRONMENT = test
# OAUTH
GOOGLE_CLIENT_ID = 472850008574-nmeepbdt4hunsk5c8krpbdmd3olc4jv6.apps.googleusercontent.com
MICROSOFT_CLIENT_ID = b411606a-e574-4f59-b7cd-10dd941b9fa3
# SENDGRID
SENDGRID_FROM_EMAIL = ricardo.carneiro@jobmaker.com.br
SENDGRID_FROM_NAME = Ricardo Carneiro
# MODERAÇÃO
MODERATOR_EMAIL = ricardo.carneiro@jobmaker.com.br
MODERATOR_EMAIL_1 = rrcgoncalves@gmail.com
MODERATOR_EMAIL_2 = rirocarneiro@gmail.com
```
---
## 🎯 **PASSO-A-PASSO COMPLETO**
### **FASE 1: TESTE ONLINE (ATUAL)**
#### **1.1 Configure Webhook no Stripe**
1. **Stripe Dashboard****Developers** → **Webhooks**
2. **Add endpoint**: `https://bcards.site/api/stripe/webhook`
3. **Select events**:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
4. **Copiar**: Signing secret (`whsec_...`)
#### **1.2 Desativar Modo Restrito no Stripe**
1. **Stripe Dashboard****Settings** → **Account settings**
2. **Business settings****"Restricted mode"** → **DESATIVAR**
#### **1.3 Configurar Variáveis no Gitea**
Use os valores atuais (modo teste):
```bash
# Para TESTE ONLINE
STRIPE_ENVIRONMENT = test
STRIPE_PUBLISHABLE_KEY = pk_test_... (sua chave atual)
STRIPE_SECRET_KEY = sk_test_... (sua chave atual)
STRIPE_WEBHOOK_SECRET = whsec_... (do webhook configurado)
```
#### **1.4 Commit e Deploy**
```bash
git add .
git commit -m "feat: deploy automatizado com configuração dinâmica via Gitea
- YAML cria appsettings.Production.json automaticamente
- Configuração via variáveis/secrets do Gitea
- Mount do arquivo no container Docker
- Suporte para teste online e produção
🔧 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
git push origin main
```
---
### **FASE 2: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)**
#### **2.1 Obter Chaves Live**
```bash
1. Stripe Dashboard → Toggle "Test mode" OFF
2. Developers → API Keys
3. Copiar: pk_live_... e sk_live_...
```
#### **2.2 Configurar Webhook Live**
```bash
1. Stripe Dashboard (Live) → Webhooks
2. Add endpoint: https://bcards.site/api/stripe/webhook
3. Copiar: whsec_live_...
```
#### **2.3 Criar Produtos Live**
```bash
# No Stripe Dashboard (Live mode):
Products → Create products para cada plano
Copiar todos os price_live_xxx IDs
```
#### **2.4 Atualizar Variáveis no Gitea**
```bash
# Mudar apenas estas variáveis:
STRIPE_ENVIRONMENT = live
STRIPE_PUBLISHABLE_KEY = pk_live_... (nova)
STRIPE_SECRET_KEY = sk_live_... (nova)
STRIPE_WEBHOOK_SECRET = whsec_live_... (nova)
```
#### **2.5 Atualizar Price IDs**
Editar `appsettings.json` com os novos price IDs live e fazer commit.
---
## ✅ **VANTAGENS DESTA ABORDAGEM**
### **🔒 Segurança:**
- ✅ Secrets nunca vão para o git
- ✅ Variables ficam na interface do Gitea
- ✅ Deploy totalmente automatizado
### **🚀 Produtividade:**
- ✅ Mudança de ambiente: apenas update de variáveis
- ✅ Sem arquivos manuais nos servidores
- ✅ Rollback fácil
- ✅ Configuração versionada via interface
### **🔧 Manutenibilidade:**
- ✅ Uma única fonte de verdade
- ✅ Fácil adicionar novos ambientes
- ✅ Logs claros no deploy
- ✅ Validação automática
---
## 📊 **FLUXO FINAL**
```mermaid
graph TD
A[git push main] --> B[Gitea Actions]
B --> C[Create appsettings.Production.json]
C --> D[Build Docker Image]
D --> E[Upload Config to Servers]
E --> F[Deploy with Volume Mount]
F --> G[✅ Site Online]
```
**Resultado**: Configuração 100% automatizada, segura e versionada! 🎉

View File

@ -5,7 +5,7 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
## 🚀 Características Principais
### ✨ Funcionalidades
- **URLs Hierárquicas**: Organização por categoria (ex: `vcart.me/corretor/jose-silva`)
- **URLs Hierárquicas**: Organização por categoria (ex: `bcards.site/corretor/jose-silva`)
- **Sistema de Pagamentos**: Integração completa com Stripe
- **3 Planos com Efeito Decoy**: Estratégia psicológica de pricing
- **Autenticação OAuth**: Google e Microsoft
@ -16,9 +16,9 @@ Um clone profissional do LinkTree desenvolvido em ASP.NET Core MVC, focado no me
- **Renderização SSR**: SEO-friendly
### 🎯 Planos e Pricing (Estratégia Decoy)
- **Básico** (R$ 9,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)*
- **Premium** (R$ 29,90/mês): Links ilimitados, temas customizáveis, analytics completo, múltiplos domínios
- **Básico** (R$ 12,90/mês): 5 links, temas básicos, analytics simples
- **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, upload de PDFs
## 🛠️ Tecnologias
@ -86,7 +86,7 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
"ClientSecret": "seu_microsoft_client_secret"
}
},
"BaseUrl": "https://vcart.me"
"BaseUrl": "https://bcards.site"
}
```
@ -94,8 +94,8 @@ Edite `appsettings.json` ou `appsettings.Development.json`:
1. Crie uma conta no [Stripe](https://stripe.com)
2. Configure os produtos e preços:
- Básico: R$ 9,90/mês
- Profissional: R$ 24,90/mês
- Básico: R$ 12,90/mês
- Profissional: R$ 25,90/mês
- Premium: R$ 29,90/mês
3. Configure webhooks para: `/webhook/stripe`
4. Eventos necessários:
@ -254,7 +254,7 @@ Sistema de analytics integrado que rastreia:
"SecretKey": "sk_live_seu_secret_key",
"WebhookSecret": "whsec_seu_webhook_secret_producao"
},
"BaseUrl": "https://vcart.me"
"BaseUrl": "https://bcards.site"
}
```
@ -303,14 +303,14 @@ Este projeto está sob a licença MIT. Veja o arquivo [LICENSE](LICENSE) para de
## 🆘 Suporte
Para suporte técnico, entre em contato:
- Email: suporte@vcart.me
- Email: suporte@bcards.site
- Discord: [Servidor da Comunidade]
- Issues: [GitHub Issues](https://github.com/seuusuario/bcards/issues)
## 📞 Contato
- **Website**: https://vcart.me
- **Email**: contato@vcart.me
- **Website**: https://bcards.site
- **Email**: contato@bcards.site
- **LinkedIn**: [Seu LinkedIn]
- **Twitter**: [@vcartme]

File diff suppressed because it is too large Load Diff

219
STRIPE-SETUP-GUIDE.md Normal file
View File

@ -0,0 +1,219 @@
# 🎯 **GUIA COMPLETO: STRIPE TESTE → PRODUÇÃO**
## 📋 **DIFERENÇAS IMPORTANTES**
### **MODO DE TESTE vs MODO RESTRITO**
| Aspecto | **Modo de Teste** | **Modo Restrito** |
|---------|-------------------|-------------------|
| 🎯 **Propósito** | Simular pagamentos | Limitar acesso público |
| 💳 **Cartões** | Apenas cartões de teste (`4242...`) | Cartões reais OU teste |
| 💰 **Dinheiro** | Não cobra dinheiro real | Pode cobrar (se live mode) |
| 🌐 **Acesso Online** | ✅ Funciona perfeitamente | ❌ Bloqueia usuários não autorizados |
| 🧪 **Para Testes** | ✅ **IDEAL** | ❌ Impede testes públicos |
### **🚨 PROBLEMA ATUAL**
Seu site provavelmente está em **Modo Restrito**, impedindo pagamentos online mesmo com cartões de teste.
---
## 🛠️ **PASSO-A-PASSO COMPLETO**
### **📝 FASE 1: PREPARAÇÃO LOCAL**
#### **1.1 Criar appsettings.Production.json**
```bash
# No seu ambiente local, crie:
cp appsettings.Production.example.json src/BCards.Web/appsettings.Production.json
```
#### **1.2 Para TESTE ONLINE (Recomendado primeiro)**
Edite `appsettings.Production.json`:
```json
{
"Stripe": {
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",
"SecretKey": "sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO",
"WebhookSecret": "SEU_WEBHOOK_PRODUÇÃO_AQUI",
"Environment": "test"
}
}
```
---
### **🔧 FASE 2: CONFIGURAÇÃO DO STRIPE**
#### **2.1 Desativar Modo Restrito (CRÍTICO)**
1. **Acesse**: https://dashboard.stripe.com
2. **Certifique-se**: Test mode **LIGADO** (toggle azul no topo)
3. **Settings** → **Account settings**
4. **Business settings** → **Restrictions**
5. **"Restricted mode"** → **DESATIVAR**
#### **2.2 Configurar Webhook de Produção**
1. **Stripe Dashboard****Developers** → **Webhooks**
2. **Add endpoint**: `https://bcards.site/api/stripe/webhook`
3. **Select events**:
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
4. **Copiar**: Signing secret → `whsec_...`
5. **Colar**: no `WebhookSecret` do seu `appsettings.Production.json`
---
### **📤 FASE 3: COMMIT E DEPLOY**
#### **3.1 Commit (SEGURO)**
```bash
# Seus arquivos de configuração agora estão seguros:
git add .
git commit -m "feat: configuração segura do Stripe para teste online
- appsettings.json: chaves removidas (seguro para git)
- appsettings.Development.json: chaves teste para desenvolvimento
- appsettings.Production.json: ignorado pelo git (chaves produção)
- Validação de ambiente Stripe adicionada
- Endpoint /stripe-info para debugging
🔧 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
# Push para main (deploy automático)
git push origin main
```
#### **3.2 Verificar Deploy**
```bash
# Aguardar deploy completar, então testar:
curl https://bcards.site/health
curl https://bcards.site/stripe-info
```
---
### **🧪 FASE 4: TESTES ONLINE**
#### **4.1 Testar Informações do Stripe**
```bash
# Verificar configuração:
https://bcards.site/stripe-info
# Deve mostrar:
{
"Environment": "test",
"IsTestMode": true,
"WebhookConfigured": true
}
```
#### **4.2 Testar Pagamento**
1. **Cadastre-se** no site online
2. **Escolha um plano**
3. **Use cartão teste**: `4242 4242 4242 4242`
4. **Data**: Qualquer data futura (ex: 12/25)
5. **CVC**: Qualquer 3 dígitos (ex: 123)
#### **4.3 Verificar Logs**
```bash
# Monitorar webhooks no Stripe Dashboard
# Developers → Webhooks → Seu endpoint → Recent deliveries
```
---
### **🚀 FASE 5: MIGRAÇÃO PARA LIVE (QUANDO PRONTO)**
#### **5.1 Obter Chaves Live**
1. **Stripe Dashboard** → Toggle "Test mode" **OFF**
2. **Developers****API Keys**
3. **Copiar**: `pk_live_...` e `sk_live_...`
#### **5.2 Criar Produtos Live**
```bash
# No Stripe Dashboard (Live mode):
# Products → Create products para cada plano
# Copiar todos os price_live_xxx
```
#### **5.3 Atualizar Produção**
```json
// appsettings.Production.json
{
"Stripe": {
"PublishableKey": "pk_live_...",
"SecretKey": "sk_live_...",
"WebhookSecret": "whsec_live_...",
"Environment": "live"
},
"Plans": {
"Basic": {
"PriceId": "price_live_basic_xxx" // ← ATUALIZAR TODOS
}
// ... outros planos
}
}
```
---
## 🔍 **DEBUGGING & MONITORAMENTO**
### **Endpoints Úteis:**
- `https://bcards.site/health` - Status da aplicação
- `https://bcards.site/stripe-info` - Configuração Stripe (apenas logados)
### **Logs Importantes:**
```bash
# Ao iniciar a aplicação, você verá:
🔧 Stripe Environment: TEST | Test Mode: True
⚠️ STRIPE TEST MODE ENABLED - Only test payments will work
```
### **Cartões de Teste:**
- **Sucesso**: `4242 4242 4242 4242`
- **Falha**: `4000 0000 0000 0002`
- **3D Secure**: `4000 0025 0000 3155`
---
## ✅ **CHECKLIST FINAL**
### **Para Teste Online:**
```bash
□ Modo Restrito DESATIVADO no Stripe
□ Webhook configurado para bcards.site
□ appsettings.Production.json criado (não no git)
□ Commit realizado (configurações seguras)
□ Deploy executado com sucesso
□ /stripe-info mostra Environment: "test"
□ Pagamento teste funciona online
```
### **Para Produção (Depois):**
```bash
□ Chaves LIVE obtidas
□ Produtos LIVE criados no Stripe
□ Price IDs atualizados
□ Webhook LIVE configurado
□ appsettings.Production.json atualizado
□ Environment: "live" configurado
□ Testes com cartões reais (pequenos valores)
□ Monitoramento ativo
```
---
## 🎯 **RESULTADO ESPERADO**
**Agora** você pode:
- ✅ **Testar online** com cartões de teste
- ✅ **Manter segurança** (chaves fora do git)
- ✅ **Monitorar** facilmente via endpoints
- ✅ **Migrar para live** quando pronto
**O site funcionará online em modo teste, permitindo que qualquer pessoa teste com cartões fake!** 🎉

View File

@ -0,0 +1,27 @@
{
"// EXEMPLO - RENOMEAR PARA appsettings.Production.json": "Este arquivo mostra como configurar produção",
"Stripe": {
"// PARA TESTE ONLINE": {
"PublishableKey": "pk_test_SUA_CHAVE_PUBLICA_AQUI",
"SecretKey": "sk_test_SUA_CHAVE_SECRETA_AQUI",
"WebhookSecret": "whsec_WEBHOOK_DO_SEU_SITE_AQUI",
"Environment": "test"
},
"// PARA PRODUÇÃO REAL": {
"PublishableKey": "pk_live_SUA_CHAVE_LIVE_AQUI",
"SecretKey": "sk_live_SUA_CHAVE_LIVE_AQUI",
"WebhookSecret": "whsec_WEBHOOK_LIVE_AQUI",
"Environment": "live"
}
},
"// INSTRUCOES": [
"1. Copie este arquivo para appsettings.Production.json",
"2. Para TESTE ONLINE: use chaves pk_test_ e sk_test_",
"3. Para PRODUÇÃO REAL: use chaves pk_live_ e sk_live_",
"4. Configure webhook para https://bcards.site/api/stripe/webhook",
"5. Modo Restrito no Stripe: desativar para permitir pagamentos públicos"
]
}

View File

@ -0,0 +1,120 @@
# Dockerfile.release - Multi-architecture build for Release environment
# Supports: linux/amd64, linux/arm64
ARG BUILDPLATFORM=linux/amd64
ARG TARGETPLATFORM
ARG VERSION=0.0.1
ARG COMMIT=unknown
# Base runtime image with multi-arch support
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8443
# Install dependencies based on target platform
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libgdiplus \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Create application directories
RUN mkdir -p /app/uploads /app/logs \
&& chmod 755 /app/uploads /app/logs
# Build stage - restore and publish
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG TARGETPLATFORM
ARG VERSION
ARG COMMIT
WORKDIR /src
# Copy project file and restore dependencies
COPY ["src/BCards.Web/BCards.Web.csproj", "src/BCards.Web/"]
# Map platform to .NET runtime identifier and restore
RUN case "$TARGETPLATFORM" in \
"linux/amd64") RID="linux-x64" ;; \
"linux/arm64") RID="linux-arm64" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
echo "🔧 Restoring for RID: $RID" && \
dotnet restore "src/BCards.Web/BCards.Web.csproj" --runtime $RID
# Copy source code
COPY . .
WORKDIR "/src/src/BCards.Web"
# Publish diretamente (build + publish em um comando)
RUN case "$TARGETPLATFORM" in \
"linux/amd64") RID="linux-x64" ;; \
"linux/arm64") RID="linux-arm64" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac && \
echo "📦 Publishing for RID: $RID" && \
dotnet publish "BCards.Web.csproj" \
-c Release \
-o /app/publish \
--no-restore \
--runtime $RID \
--self-contained false \
-p:PublishReadyToRun=false \
-p:PublishSingleFile=false \
-p:UseAppHost=false \
-p:Version=$VERSION \
-p:InformationalVersion=$COMMIT
# Final stage - runtime optimized for Release environment
FROM base AS final
ARG VERSION=0.0.1
ARG COMMIT=unknown
ARG TARGETPLATFORM
# Metadata labels
LABEL maintainer="BCards Team"
LABEL version=$VERSION
LABEL commit=$COMMIT
LABEL platform=$TARGETPLATFORM
LABEL environment="release"
WORKDIR /app
# Copy published application
COPY --from=build /app/publish .
# Create non-root user for security
RUN groupadd -r bcards && useradd -r -g bcards bcards \
&& chown -R bcards:bcards /app
# Environment variables for Release
ENV ASPNETCORE_ENVIRONMENT=Release
ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV DOTNET_EnableDiagnostics=0
ENV DOTNET_USE_POLLING_FILE_WATCHER=true
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
# Platform-specific optimizations
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
echo "🔧 Applying ARM64 optimizations..." && \
echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \
echo 'export DOTNET_TC_QuickJitForLoops=1' >> /etc/environment && \
echo 'export DOTNET_ReadyToRun=0' >> /etc/environment; \
else \
echo "🔧 Applying AMD64 optimizations..." && \
echo 'export DOTNET_TieredPGO=1' >> /etc/environment && \
echo 'export DOTNET_ReadyToRun=1' >> /etc/environment; \
fi
# Health check endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# Switch to non-root user
USER bcards
# Entry point with optimized runtime settings
ENTRYPOINT ["dotnet", "BCards.Web.dll"]

452
categorias.json Normal file
View File

@ -0,0 +1,452 @@
[
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"name": "Floricultura",
"slug": "floricultura",
"icon": "🌸",
"seoKeywords": [
"floricultura",
"flores",
"buquê",
"plantas",
"arranjos",
"casamento"
],
"description": "Floriculturas, arranjos florais e comércio de plantas ornamentais",
"isActive": true
},
{
"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
},
{
"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
}
]

34
clean-build.sh Normal file
View File

@ -0,0 +1,34 @@
#!/bin/bash
echo "🧹 Iniciando limpeza completa do projeto BCards..."
# 1. Limpar todos os caches NuGet
echo "📦 Limpando cache NuGet..."
dotnet nuget locals all --clear
# 2. Remover pastas bin/obj recursivamente
echo "🗑️ Removendo pastas bin/obj..."
find . -name "bin" -type d -exec rm -rf {} + 2>/dev/null || true
find . -name "obj" -type d -exec rm -rf {} + 2>/dev/null || true
# 3. Limpar solution
echo "🧽 Executando dotnet clean..."
dotnet clean --verbosity quiet
# 4. Restaurar packages sem cache
echo "📥 Restaurando packages..."
dotnet restore --no-cache --force --verbosity quiet
# 5. Build completo
echo "🔨 Executando build..."
dotnet build --no-restore --verbosity quiet
if [ $? -eq 0 ]; then
echo "✅ Build concluído com sucesso!"
echo "🚀 Pronto para executar: dotnet run"
else
echo "❌ Build falhou! Verifique os erros acima."
exit 1
fi
echo "🎉 Limpeza completa finalizada!"

68
deploy-manual.ps1 Normal file
View File

@ -0,0 +1,68 @@
# Deploy manual corrigido (sem problemas de quebra de linha)
param(
[string]$Tag = "latest-manual"
)
Write-Host "🏗️ Building and deploying BCards..." -ForegroundColor Green
try {
# 1. Build da imagem
Write-Host "📦 Building Docker image..." -ForegroundColor Yellow
# Build e push da imagem
docker buildx build --platform linux/arm64 --tag "registry.redecarneir.us/bcards:$Tag" --tag "registry.redecarneir.us/bcards:latest" --push --no-cache .
if ($LASTEXITCODE -ne 0) {
throw "Build failed"
}
Write-Host "✅ Image built and pushed successfully!" -ForegroundColor Green
# 2. Deploy no servidor 1
Write-Host "🚀 Deploying to server 1 (141.148.162.114)..." -ForegroundColor Yellow
# Usar script inline mais simples
$deployScript1 = @'
echo "🔄 Updating server 1..."
docker stop bcards-prod || true
docker rm bcards-prod || true
docker rmi registry.redecarneir.us/bcards:latest || true
docker pull registry.redecarneir.us/bcards:latest
docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5343 registry.redecarneir.us/bcards:latest
echo "✅ Server 1 updated"
'@
$deployScript1 | ssh ubuntu@141.148.162.114 'bash -s'
# 3. Deploy no servidor 2
Write-Host "🚀 Deploying to server 2 (129.146.116.218)..." -ForegroundColor Yellow
$deployScript2 = @'
echo "🔄 Updating server 2..."
docker stop bcards-prod || true
docker rm bcards-prod || true
docker rmi registry.redecarneir.us/bcards:latest || true
docker pull registry.redecarneir.us/bcards:latest
docker run -d --name bcards-prod --restart unless-stopped --network host -e ASPNETCORE_ENVIRONMENT=Production -e ASPNETCORE_URLS=http://+:8080 -e "MongoDb__ConnectionString=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin" -e MongoDb__DatabaseName=BCardsDB -e Logging__LogLevel__Default=Debug -e Serilog__SeqUrl=http://localhost:5342 registry.redecarneir.us/bcards:latest
echo "✅ Server 2 updated"
'@
$deployScript2 | ssh ubuntu@129.146.116.218 'bash -s'
# 4. Health check
Write-Host "🏥 Running health checks..." -ForegroundColor Yellow
Start-Sleep 30
Write-Host "Testing server 1..." -ForegroundColor Cyan
ssh ubuntu@141.148.162.114 'curl -f http://localhost:8080/health || echo "Server 1 health check failed"'
Write-Host "Testing server 2..." -ForegroundColor Cyan
ssh ubuntu@129.146.116.218 'curl -f http://localhost:8080/health || echo "Server 2 health check failed"'
Write-Host "✅ Manual deploy completed successfully!" -ForegroundColor Green
Write-Host "🌐 Test your CSS changes now!" -ForegroundColor Magenta
} catch {
Write-Host "❌ Deploy failed: $_" -ForegroundColor Red
exit 1
}

View File

@ -0,0 +1,88 @@
#!/bin/bash
# Script para limpar containers standalone do BCards
# Deve ser executado DEPOIS que o Swarm estiver rodando corretamente
set -e
echo "🔍 Verificando containers standalone do BCards..."
# Lista de possíveis nomes de containers standalone
STANDALONE_CONTAINERS=(
"bcards-prod"
"bcards-release"
"bcards-app"
)
# Verifica se o Swarm está rodando
echo "✅ Verificando status do Docker Swarm..."
if ! docker info | grep -q "Swarm: active"; then
echo "❌ ERRO: Docker Swarm não está ativo neste servidor!"
echo "Este script só deve ser executado em servidores do Swarm."
exit 1
fi
# Verifica se o serviço do Swarm está rodando
echo "✅ Verificando serviço bcards_bcards-app no Swarm..."
if ! docker service ls | grep -q "bcards_bcards-app"; then
echo "❌ ERRO: Serviço bcards_bcards-app não encontrado no Swarm!"
echo "Certifique-se de que o deploy do Swarm foi feito antes de executar este script."
exit 1
fi
# Mostra status do serviço Swarm
echo ""
echo "📊 Status atual do serviço Swarm:"
docker service ls --filter name=bcards_bcards-app
echo ""
docker service ps bcards_bcards-app --filter "desired-state=running" | head -10
echo ""
# Verifica se há containers standalone rodando
FOUND_CONTAINERS=false
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
FOUND_CONTAINERS=true
break
fi
done
if [ "$FOUND_CONTAINERS" = false ]; then
echo "✅ Nenhum container standalone encontrado. Sistema está OK!"
exit 0
fi
# Lista containers encontrados
echo "⚠️ Containers standalone encontrados:"
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
echo " - $container_name"
docker ps -a --filter "name=^${container_name}$" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}"
fi
done
echo ""
# Pergunta confirmação
read -p "🗑️ Deseja remover estes containers standalone? (sim/não): " -r
echo
if [[ ! $REPLY =~ ^[Ss][Ii][Mm]$ ]]; then
echo "❌ Operação cancelada pelo usuário."
exit 0
fi
# Remove os containers
echo "🧹 Removendo containers standalone..."
for container_name in "${STANDALONE_CONTAINERS[@]}"; do
if docker ps -a --format '{{.Names}}' | grep -q "^${container_name}$"; then
echo " Parando e removendo: $container_name"
docker stop "$container_name" 2>/dev/null || true
docker rm "$container_name" 2>/dev/null || true
fi
done
echo ""
echo "✅ Limpeza concluída!"
echo ""
echo "📊 Status final do serviço Swarm:"
docker service ls --filter name=bcards_bcards-app
echo ""
docker service ps bcards_bcards-app --filter "desired-state=running" | head -10

View File

@ -0,0 +1,52 @@
version: '3.8'
services:
bcards-release:
image: ${BCARDS_IMAGE}
networks:
- bcards-net
deploy:
replicas: 1
update_config:
parallelism: 1
delay: 10s
order: start-first
monitor: 60s
failure_action: rollback
rollback_config:
parallelism: 0
delay: 5s
environment:
ASPNETCORE_ENVIRONMENT: Release
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
# MongoDB local (Core i5)
MongoDb__ConnectionString: mongodb://192.168.0.100:27017/BCardsDB
MongoDb__DatabaseName: BCardsDB
DataProtection__Mongo__ConnectionString: mongodb://192.168.0.100:27017/BCardsDB
DataProtection__Mongo__DatabaseName: BCardsDB
DataProtection__Mongo__CollectionName: DataProtectionKeys
# OpenSearch local (Core i5)
Serilog__OpenSearchUrl: http://192.168.0.100:9200
Serilog__OpenSearchFallback: http://192.168.0.100:9200
# Stripe test keys (same as development)
Stripe__PublishableKey: pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS
Stripe__SecretKey: sk_test_51RjUmIBMIadsOxJVeqsMFxnZ8ePR7d8IbnaF4sAwBVJv9rrfODPEQ2C9fF3beoABpITdfzEk0ZDzGTTQfvKv63xI00PeZoABGO
Stripe__WebhookSecret: whsec_8d189c137ff170ab5e62498003512b9d073e2db50c50ed7d8712b7ef11a37543
Stripe__Environment: test
Logging__LogLevel__Default: Debug
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
ports:
- published: 28080
target: 8080
protocol: tcp
mode: ingress
networks:
bcards-net:
external: true

52
deploy/docker-stack.yml Normal file
View File

@ -0,0 +1,52 @@
version: '3.8'
configs:
bcards-appsettings:
external: true
services:
bcards-app:
image: registry.redecarneir.us/bcards:latest
networks:
- bcards-net
deploy:
replicas: 4
placement:
max_replicas_per_node: 2
update_config:
parallelism: 1
order: stop-first
delay: 10s
monitor: 60s
failure_action: rollback
rollback_config:
parallelism: 0
delay: 5s
configs:
- source: bcards-appsettings
target: /app/appsettings.Production.json
mode: 0444
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
MongoDb__ConnectionString: mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin
MongoDb__DatabaseName: BCardsDB
Serilog__OpenSearchUrl: http://141.148.162.114:19201
Serilog__OpenSearchFallback: http://129.146.116.218:19202
Logging__LogLevel__Default: Information
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
ports:
- published: 8080
target: 8080
protocol: tcp
mode: ingress
networks:
bcards-net:
external: true

160
docker-compose.staging.yml Normal file
View File

@ -0,0 +1,160 @@
version: '3.8'
services:
bcards-web:
image: ${REGISTRY:-registry.redecarneir.us}/bcards:${IMAGE_TAG:-release-latest}
container_name: bcards-staging
restart: unless-stopped
ports:
- "8090:8080"
- "8453:8443"
environment:
# Core ASP.NET Configuration
- ASPNETCORE_ENVIRONMENT=Release
- ASPNETCORE_URLS=http://+:8080
- ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
# MongoDB Configuration
- MongoDb__ConnectionString=${MONGODB_CONNECTION_STRING:-mongodb://192.168.0.100:27017/BCardsDB}
- MongoDb__DatabaseName=BCardsDB
# Application Settings
- AppSettings__Environment=Staging
- AppSettings__Version=${IMAGE_TAG:-unknown}
- AppSettings__AllowedHosts=*
# Logging Configuration
- Logging__LogLevel__Default=Information
- Logging__LogLevel__Microsoft.AspNetCore=Warning
- Logging__LogLevel__BCards=Debug
# Performance Optimizations
- DOTNET_RUNNING_IN_CONTAINER=true
- DOTNET_EnableDiagnostics=0
- DOTNET_USE_POLLING_FILE_WATCHER=true
- DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
- DOTNET_TieredPGO=1
- DOTNET_TC_QuickJitForLoops=1
# Security Headers
- ASPNETCORE_HTTPS_PORT=8443
- ASPNETCORE_Kestrel__Certificates__Default__Path=/app/certs/cert.pfx
- ASPNETCORE_Kestrel__Certificates__Default__Password=${CERT_PASSWORD:-}
# Redis Configuration (if needed)
- Redis__ConnectionString=localhost:6379
volumes:
# Application logs
- ./logs:/app/logs:rw
# File uploads (if needed)
- ./uploads:/app/uploads:rw
# SSL certificates (if using HTTPS)
# - ./certs:/app/certs:ro
networks:
- bcards-staging-network
# Health check configuration
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# Resource limits for staging environment
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
# Logging configuration
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
# Platform specification (will use the appropriate arch from multi-arch image)
# platform: linux/amd64 # Uncomment if forcing specific architecture
# Security options
security_opt:
- no-new-privileges:true
read_only: false # Set to true for extra security, but may need volume mounts for temp files
# Process limits
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
# Optional: Redis for caching (if application uses it)
redis:
image: redis:7-alpine
container_name: bcards-redis-staging
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_staging_data:/data
networks:
- bcards-staging-network
deploy:
resources:
limits:
memory: 256M
cpus: '0.5'
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
# Optional: Nginx reverse proxy for additional features
nginx:
image: nginx:alpine
container_name: bcards-nginx-staging
restart: unless-stopped
ports:
- "8091:80"
- "8454:443"
volumes:
- ./nginx/staging.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/ssl:/etc/ssl/certs:ro
- ./logs/nginx:/var/log/nginx:rw
depends_on:
- bcards-web
networks:
- bcards-staging-network
deploy:
resources:
limits:
memory: 128M
cpus: '0.25'
# Named volumes for persistent data
volumes:
redis_staging_data:
driver: local
driver_opts:
type: none
o: bind
device: ./data/redis
# Network for staging environment
networks:
bcards-staging-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

369
scripts/deploy-release.sh Normal file
View File

@ -0,0 +1,369 @@
#!/bin/bash
# Deploy script for Release environment with multi-architecture support
# Usage: ./deploy-release.sh <IMAGE_TAG>
set -euo pipefail
# Configuration
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
readonly DEPLOY_DIR="/opt/bcards-staging"
readonly DOCKER_COMPOSE_FILE="docker-compose.staging.yml"
readonly CONTAINER_NAME="bcards-staging"
readonly HEALTH_CHECK_URL="http://localhost:8090/health"
readonly MAX_HEALTH_CHECK_ATTEMPTS=10
readonly HEALTH_CHECK_INTERVAL=10
readonly ROLLBACK_TIMEOUT=300
# Colors for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Cleanup function
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
log_error "Deployment failed with exit code $exit_code"
rollback_deployment
fi
exit $exit_code
}
# Set trap for cleanup on exit
trap cleanup EXIT
# Validate input parameters
validate_input() {
if [ $# -ne 1 ]; then
log_error "Usage: $0 <IMAGE_TAG>"
exit 1
fi
local image_tag="$1"
if [[ ! "$image_tag" =~ ^[a-zA-Z0-9._-]+$ ]]; then
log_error "Invalid image tag format: $image_tag"
exit 1
fi
}
# Check prerequisites
check_prerequisites() {
log_info "Checking prerequisites..."
# Check if Docker is running
if ! docker info >/dev/null 2>&1; then
log_error "Docker is not running or not accessible"
exit 1
fi
# Check if docker-compose is available
if ! command -v docker-compose >/dev/null 2>&1; then
log_error "docker-compose is not installed"
exit 1
fi
# Check if deployment directory exists
if [ ! -d "$DEPLOY_DIR" ]; then
log_info "Creating deployment directory: $DEPLOY_DIR"
mkdir -p "$DEPLOY_DIR"
fi
log_success "Prerequisites check passed"
}
# Backup current deployment
backup_current_deployment() {
log_info "Backing up current deployment..."
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_dir="$DEPLOY_DIR/backups/$timestamp"
mkdir -p "$backup_dir"
# Backup environment file if exists
if [ -f "$DEPLOY_DIR/.env" ]; then
cp "$DEPLOY_DIR/.env" "$backup_dir/.env.backup"
cp "$DEPLOY_DIR/.env" "$DEPLOY_DIR/.env.backup"
log_info "Environment file backed up"
fi
# Backup docker-compose file if exists
if [ -f "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" ]; then
cp "$DEPLOY_DIR/$DOCKER_COMPOSE_FILE" "$backup_dir/${DOCKER_COMPOSE_FILE}.backup"
log_info "Docker compose file backed up"
fi
# Get current container image for potential rollback
if docker ps --format "table {{.Names}}\t{{.Image}}" | grep -q "$CONTAINER_NAME"; then
local current_image=$(docker inspect --format='{{.Config.Image}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
if [ -n "$current_image" ]; then
echo "$current_image" > "$DEPLOY_DIR/.previous_image"
log_info "Current image backed up: $current_image"
fi
fi
log_success "Backup completed: $backup_dir"
}
# Test MongoDB connectivity
test_mongodb_connection() {
log_info "Testing MongoDB connectivity..."
local mongodb_host="192.168.0.100"
local mongodb_port="27017"
# Test basic connectivity
if timeout 10 bash -c "</dev/tcp/$mongodb_host/$mongodb_port" 2>/dev/null; then
log_success "MongoDB connection test passed"
else
log_error "Cannot connect to MongoDB at $mongodb_host:$mongodb_port"
return 1
fi
# Run detailed MongoDB test script if available
if [ -f "$SCRIPT_DIR/test-mongodb-connection.sh" ]; then
log_info "Running detailed MongoDB connection tests..."
bash "$SCRIPT_DIR/test-mongodb-connection.sh"
fi
}
# Pull new Docker image
pull_docker_image() {
local image_tag="$1"
local full_image="registry.redecarneir.us/bcards:$image_tag"
log_info "Pulling Docker image: $full_image"
# Pull the multi-arch image
if docker pull "$full_image"; then
log_success "Image pulled successfully"
else
log_error "Failed to pull image: $full_image"
return 1
fi
# Verify image architecture
local image_arch=$(docker inspect --format='{{.Architecture}}' "$full_image" 2>/dev/null || echo "unknown")
local system_arch=$(uname -m)
log_info "Image architecture: $image_arch"
log_info "System architecture: $system_arch"
# Convert system arch format to Docker format for comparison
case "$system_arch" in
x86_64) system_arch="amd64" ;;
aarch64) system_arch="arm64" ;;
esac
if [ "$image_arch" = "$system_arch" ] || [ "$image_arch" = "unknown" ]; then
log_success "Image architecture is compatible"
else
log_warning "Image architecture ($image_arch) may not match system ($system_arch), but multi-arch support should handle this"
fi
}
# Deploy new version
deploy_new_version() {
local image_tag="$1"
log_info "Deploying new version with tag: $image_tag"
# Copy docker-compose file to deployment directory
cp "$PROJECT_ROOT/$DOCKER_COMPOSE_FILE" "$DEPLOY_DIR/"
# Create/update environment file
cat > "$DEPLOY_DIR/.env" << EOF
IMAGE_TAG=$image_tag
REGISTRY=registry.redecarneir.us
MONGODB_CONNECTION_STRING=mongodb://admin:c4rn31r0@129.146.116.218:27017,141.148.162.114:27017/BCardsDB?replicaSet=rs0&authSource=admin
ASPNETCORE_ENVIRONMENT=Release
CERT_PASSWORD=
EOF
# Stop existing containers
cd "$DEPLOY_DIR"
if docker-compose -f "$DOCKER_COMPOSE_FILE" ps -q | grep -q .; then
log_info "Stopping existing containers..."
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
fi
# Start new containers
log_info "Starting new containers..."
docker-compose -f "$DOCKER_COMPOSE_FILE" up -d
# Wait for containers to start
sleep 15
log_success "New version deployed"
}
# Health check
perform_health_check() {
log_info "Performing health check..."
local attempt=1
while [ $attempt -le $MAX_HEALTH_CHECK_ATTEMPTS ]; do
log_info "Health check attempt $attempt/$MAX_HEALTH_CHECK_ATTEMPTS"
# Check if container is running
if ! docker ps --format "table {{.Names}}" | grep -q "$CONTAINER_NAME"; then
log_warning "Container $CONTAINER_NAME is not running"
else
# Check application health endpoint
if curl -f -s "$HEALTH_CHECK_URL" >/dev/null 2>&1; then
log_success "Health check passed"
return 0
fi
# Check if the application is responding on port 80
if curl -f -s "http://localhost:8090/" >/dev/null 2>&1; then
log_success "Application is responding (health endpoint may not be configured)"
return 0
fi
fi
if [ $attempt -eq $MAX_HEALTH_CHECK_ATTEMPTS ]; then
log_error "Health check failed after $MAX_HEALTH_CHECK_ATTEMPTS attempts"
return 1
fi
log_info "Waiting $HEALTH_CHECK_INTERVAL seconds before next attempt..."
sleep $HEALTH_CHECK_INTERVAL
((attempt++))
done
}
# Rollback deployment
rollback_deployment() {
log_warning "Initiating rollback..."
cd "$DEPLOY_DIR"
# Stop current containers
if [ -f "$DOCKER_COMPOSE_FILE" ]; then
docker-compose -f "$DOCKER_COMPOSE_FILE" down --remove-orphans
fi
# Restore previous environment if backup exists
if [ -f ".env.backup" ]; then
mv ".env.backup" ".env"
log_info "Previous environment restored"
fi
# Try to start previous version if image is available
if [ -f ".previous_image" ]; then
local previous_image=$(cat ".previous_image")
log_info "Attempting to restore previous image: $previous_image"
# Update .env with previous image tag
local previous_tag=$(echo "$previous_image" | cut -d':' -f2)
sed -i "s/IMAGE_TAG=.*/IMAGE_TAG=$previous_tag/" .env 2>/dev/null || true
# Try to start previous version
if docker-compose -f "$DOCKER_COMPOSE_FILE" up -d; then
log_success "Rollback completed successfully"
else
log_error "Rollback failed - manual intervention required"
fi
else
log_warning "No previous version found for rollback"
fi
}
# Cleanup old images and containers
cleanup_old_resources() {
log_info "Cleaning up old Docker resources..."
# Remove dangling images
if docker images -f "dangling=true" -q | head -1 | grep -q .; then
docker rmi $(docker images -f "dangling=true" -q) || true
log_info "Dangling images removed"
fi
# Remove old backups (keep last 5)
if [ -d "$DEPLOY_DIR/backups" ]; then
find "$DEPLOY_DIR/backups" -maxdepth 1 -type d -name "20*" | sort -r | tail -n +6 | xargs rm -rf || true
log_info "Old backups cleaned up"
fi
log_success "Cleanup completed"
}
# Display deployment summary
display_summary() {
local image_tag="$1"
log_success "Deployment Summary:"
echo "=================================="
echo "🚀 Image Tag: $image_tag"
echo "🌐 Environment: Release (Staging)"
echo "🔗 Application URL: http://localhost:8090"
echo "🔗 Health Check: $HEALTH_CHECK_URL"
echo "🗄️ MongoDB: 192.168.0.100:27017"
echo "📁 Deploy Directory: $DEPLOY_DIR"
echo "🐳 Container: $CONTAINER_NAME"
# Show container status
echo ""
echo "Container Status:"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "(NAMES|$CONTAINER_NAME)" || true
# Show image info
echo ""
echo "Image Information:"
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.CreatedAt}}\t{{.Size}}" | grep -E "(REPOSITORY|bcards)" | head -5 || true
echo "=================================="
}
# Main deployment function
main() {
local image_tag="$1"
log_info "Starting deployment process for BCards Release environment"
log_info "Target image tag: $image_tag"
log_info "Target architecture: $(uname -m)"
log_info "Deploy directory: $DEPLOY_DIR"
# Execute deployment steps
validate_input "$@"
check_prerequisites
test_mongodb_connection
backup_current_deployment
pull_docker_image "$image_tag"
deploy_new_version "$image_tag"
# Perform health check (rollback handled by trap if this fails)
if perform_health_check; then
cleanup_old_resources
display_summary "$image_tag"
log_success "Deployment completed successfully!"
else
log_error "Health check failed - rollback will be triggered"
exit 1
fi
}
# Run main function with all arguments
main "$@"

84
scripts/swarm_deploy.sh Normal file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 4 ]]; then
echo "Usage: $0 <stack-name> <service-name> <stack-file> <health-url> [expected-replicas]" >&2
exit 1
fi
STACK_NAME="$1"
SERVICE_NAME="$2"
STACK_FILE="$3"
HEALTH_URL="$4"
EXPECTED_REPLICAS="${5:-4}"
SERVICE_FQDN="${STACK_NAME}_${SERVICE_NAME}"
LOG_PREFIX="[swarm-deploy]"
log() {
printf '%s %s %s\n' "$(date --iso-8601=seconds)" "$LOG_PREFIX" "$*"
}
retry() {
local attempts=$1; shift
local delay=$1; shift
local n=1
while true; do
if "$@"; then
return 0
fi
if (( n == attempts )); then
return 1
fi
((n++))
sleep "$delay"
done
}
log "Deploying stack '${STACK_NAME}' using ${STACK_FILE}"
docker stack deploy --compose-file "$STACK_FILE" "$STACK_NAME"
log "Waiting for service ${SERVICE_FQDN} to reach ${EXPECTED_REPLICAS} replicas"
retries=24
while (( retries > 0 )); do
replicas_raw=$(docker service ls --filter "name=${SERVICE_FQDN}" --format '{{.Replicas}}' || true)
if [[ -z "$replicas_raw" ]]; then
log "Service ${SERVICE_FQDN} not found yet; retrying"
sleep 5
((retries--))
continue
fi
replicas_clean=${replicas_raw%% (*}
running=${replicas_clean%%/*}
desired=${replicas_clean##*/}
if [[ "$running" == "$desired" && "$running" == "$EXPECTED_REPLICAS" ]]; then
log "Service reached desired replica count: ${running}/${desired}"
break
fi
log "Current replicas ${running}/${desired}; waiting..."
sleep 5
((retries--))
done
if (( retries == 0 )); then
log "Timed out waiting for replicas"
docker service ps "$SERVICE_FQDN"
exit 1
fi
log "Checking task states"
if ! docker service ps "$SERVICE_FQDN" --no-trunc --filter 'desired-state=Running' --format '{{.CurrentState}}' | grep -q '^Running '; then
log "Some tasks are not running"
docker service ps "$SERVICE_FQDN"
exit 1
fi
log "Running health check against ${HEALTH_URL}"
if ! retry 3 5 curl -fsS "$HEALTH_URL"; then
log "Health check failed; rolling back service"
docker service update --rollback "$SERVICE_FQDN" || true
exit 1
fi
log "Health check succeeded"
log "Deployment finished successfully"

View File

@ -0,0 +1,495 @@
#!/bin/bash
# MongoDB Connection Test Script for Release Environment
# Tests connectivity, database operations, and index validation
set -euo pipefail
# Configuration
readonly MONGODB_HOST="${MONGODB_HOST:-192.168.0.100}"
readonly MONGODB_PORT="${MONGODB_PORT:-27017}"
readonly DATABASE_NAME="${DATABASE_NAME:-BCardsDB}"
readonly CONNECTION_STRING="mongodb://${MONGODB_HOST}:${MONGODB_PORT}/${DATABASE_NAME}"
readonly TIMEOUT=30
# Colors for output
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Test basic TCP connectivity
test_tcp_connection() {
log_info "Testing TCP connection to $MONGODB_HOST:$MONGODB_PORT..."
if timeout $TIMEOUT bash -c "</dev/tcp/$MONGODB_HOST/$MONGODB_PORT" 2>/dev/null; then
log_success "TCP connection successful"
return 0
else
log_error "TCP connection failed"
return 1
fi
}
# Test MongoDB connectivity using mongosh if available
test_mongodb_with_mongosh() {
if ! command -v mongosh >/dev/null 2>&1; then
log_warning "mongosh not available, skipping MongoDB shell tests"
return 1
fi
log_info "Testing MongoDB connection with mongosh..."
# Test basic connection
local test_output=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "db.runCommand({ping: 1})" 2>/dev/null || echo "FAILED")
if [[ "$test_output" == *"ok"* ]]; then
log_success "MongoDB ping successful"
else
log_error "MongoDB ping failed"
return 1
fi
# Test database access
log_info "Testing database operations..."
local db_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
try {
// Test basic database operations
db.connection_test.insertOne({test: true, timestamp: new Date()});
var result = db.connection_test.findOne({test: true});
db.connection_test.deleteOne({test: true});
print('DATABASE_ACCESS_OK');
} catch (e) {
print('DATABASE_ACCESS_FAILED: ' + e.message);
}
" 2>/dev/null || echo "DATABASE_ACCESS_FAILED")
if [[ "$db_test" == *"DATABASE_ACCESS_OK"* ]]; then
log_success "Database operations test passed"
else
log_error "Database operations test failed: $db_test"
return 1
fi
return 0
}
# Test MongoDB connectivity using Python if available
test_mongodb_with_python() {
if ! command -v python3 >/dev/null 2>&1; then
log_warning "Python3 not available, skipping Python MongoDB tests"
return 1
fi
log_info "Testing MongoDB connection with Python..."
python3 << EOF
import sys
try:
import pymongo
from pymongo import MongoClient
import socket
# Test connection
client = MongoClient("$CONNECTION_STRING", serverSelectionTimeoutMS=$((TIMEOUT * 1000)))
# Test ping
client.admin.command('ping')
print("MongoDB ping successful (Python)")
# Test database access
db = client["$DATABASE_NAME"]
# Insert test document
test_collection = db.connection_test
result = test_collection.insert_one({"test": True, "source": "python"})
# Read test document
doc = test_collection.find_one({"_id": result.inserted_id})
if doc:
print("Database read/write test passed (Python)")
# Cleanup
test_collection.delete_one({"_id": result.inserted_id})
client.close()
print("PYTHON_TEST_SUCCESS")
except ImportError:
print("PyMongo not installed, skipping Python tests")
sys.exit(1)
except Exception as e:
print(f"Python MongoDB test failed: {e}")
sys.exit(1)
EOF
local python_result=$?
if [ $python_result -eq 0 ]; then
log_success "Python MongoDB test passed"
return 0
else
log_error "Python MongoDB test failed"
return 1
fi
}
# Test using Docker MongoDB client
test_mongodb_with_docker() {
if ! command -v docker >/dev/null 2>&1; then
log_warning "Docker not available, skipping Docker MongoDB tests"
return 1
fi
log_info "Testing MongoDB connection using Docker MongoDB client..."
# Use official MongoDB image to test connection
local docker_test=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
try {
db.runCommand({ping: 1});
db.connection_test.insertOne({test: true, source: 'docker', timestamp: new Date()});
var doc = db.connection_test.findOne({source: 'docker'});
db.connection_test.deleteOne({source: 'docker'});
print('DOCKER_TEST_SUCCESS');
} catch (e) {
print('DOCKER_TEST_FAILED: ' + e.message);
}
" 2>/dev/null || echo "DOCKER_TEST_FAILED")
if [[ "$docker_test" == *"DOCKER_TEST_SUCCESS"* ]]; then
log_success "Docker MongoDB test passed"
return 0
else
log_error "Docker MongoDB test failed: $docker_test"
return 1
fi
}
# Test MongoDB from application container
test_from_application_container() {
local container_name="bcards-staging"
if ! docker ps --format "{{.Names}}" | grep -q "^${container_name}$"; then
log_warning "Application container '$container_name' not running, skipping application test"
return 1
fi
log_info "Testing MongoDB connection from application container..."
# Test connection from the application container
local app_test=$(docker exec "$container_name" timeout 10 bash -c "
# Test TCP connection
if timeout 5 bash -c '</dev/tcp/$MONGODB_HOST/$MONGODB_PORT' 2>/dev/null; then
echo 'APP_TCP_OK'
else
echo 'APP_TCP_FAILED'
exit 1
fi
# Test HTTP health endpoint if available
if curl -f -s http://localhost:8080/health >/dev/null 2>&1; then
echo 'APP_HEALTH_OK'
else
echo 'APP_HEALTH_FAILED'
fi
" 2>/dev/null || echo "APP_TEST_FAILED")
if [[ "$app_test" == *"APP_TCP_OK"* ]]; then
log_success "Application container can connect to MongoDB"
if [[ "$app_test" == *"APP_HEALTH_OK"* ]]; then
log_success "Application health check passed"
else
log_warning "Application health check failed - app may still be starting"
fi
return 0
else
log_error "Application container cannot connect to MongoDB"
return 1
fi
}
# Check MongoDB server status and version
check_mongodb_status() {
log_info "Checking MongoDB server status..."
# Try multiple methods to check status
local status_checked=false
# Method 1: Using mongosh
if command -v mongosh >/dev/null 2>&1; then
local server_status=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
try {
var status = db.runCommand({serverStatus: 1});
print('MongoDB Version: ' + status.version);
print('Uptime: ' + status.uptime + ' seconds');
print('Connections: ' + status.connections.current + '/' + status.connections.available);
print('STATUS_CHECK_OK');
} catch (e) {
print('STATUS_CHECK_FAILED: ' + e.message);
}
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
if [[ "$server_status" == *"STATUS_CHECK_OK"* ]]; then
echo "$server_status" | grep -v "STATUS_CHECK_OK"
log_success "MongoDB server status check passed"
status_checked=true
fi
fi
# Method 2: Using Docker if mongosh failed
if [ "$status_checked" = false ] && command -v docker >/dev/null 2>&1; then
local docker_status=$(timeout $TIMEOUT docker run --rm mongo:7.0 mongosh "$CONNECTION_STRING" --quiet --eval "
try {
var status = db.runCommand({serverStatus: 1});
print('MongoDB Version: ' + status.version);
print('STATUS_CHECK_OK');
} catch (e) {
print('STATUS_CHECK_FAILED: ' + e.message);
}
" 2>/dev/null || echo "STATUS_CHECK_FAILED")
if [[ "$docker_status" == *"STATUS_CHECK_OK"* ]]; then
echo "$docker_status" | grep -v "STATUS_CHECK_OK"
log_success "MongoDB server status check passed (via Docker)"
status_checked=true
fi
fi
if [ "$status_checked" = false ]; then
log_warning "Could not retrieve MongoDB server status"
return 1
fi
return 0
}
# Test BCards specific collections and indexes
test_bcards_collections() {
if ! command -v mongosh >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then
log_warning "Cannot test BCards collections - no MongoDB client available"
return 1
fi
log_info "Testing BCards specific collections and indexes..."
local mongo_cmd="mongosh"
local docker_prefix=""
if ! command -v mongosh >/dev/null 2>&1; then
mongo_cmd="docker run --rm mongo:7.0 mongosh"
docker_prefix="timeout $TIMEOUT "
fi
local collections_test=$(${docker_prefix}${mongo_cmd} "$CONNECTION_STRING" --quiet --eval "
try {
// Check required collections
var collections = db.listCollectionNames();
var requiredCollections = ['users', 'userpages', 'categories'];
var missingCollections = [];
requiredCollections.forEach(function(collection) {
if (collections.indexOf(collection) === -1) {
missingCollections.push(collection);
}
});
if (missingCollections.length > 0) {
print('Missing collections: ' + missingCollections.join(', '));
} else {
print('All required collections exist');
}
// Check indexes on userpages collection
if (collections.indexOf('userpages') !== -1) {
var indexes = db.userpages.getIndexes();
print('UserPages collection has ' + indexes.length + ' indexes');
// Check for important compound index
var hasCompoundIndex = indexes.some(function(index) {
return index.key && index.key.category && index.key.slug;
});
if (hasCompoundIndex) {
print('Required compound index (category, slug) exists');
} else {
print('WARNING: Compound index (category, slug) is missing');
}
}
print('COLLECTIONS_TEST_OK');
} catch (e) {
print('COLLECTIONS_TEST_FAILED: ' + e.message);
}
" 2>/dev/null || echo "COLLECTIONS_TEST_FAILED")
if [[ "$collections_test" == *"COLLECTIONS_TEST_OK"* ]]; then
echo "$collections_test" | grep -v "COLLECTIONS_TEST_OK"
log_success "BCards collections test passed"
return 0
else
log_warning "BCards collections test had issues: $collections_test"
return 1
fi
}
# Performance test
test_mongodb_performance() {
log_info "Running basic performance test..."
if ! command -v mongosh >/dev/null 2>&1; then
log_warning "mongosh not available, skipping performance test"
return 1
fi
local perf_test=$(timeout $TIMEOUT mongosh "$CONNECTION_STRING" --quiet --eval "
try {
var start = new Date();
// Insert test documents
var docs = [];
for (var i = 0; i < 100; i++) {
docs.push({test: true, index: i, timestamp: new Date()});
}
db.performance_test.insertMany(docs);
// Read test
var count = db.performance_test.countDocuments({test: true});
// Update test
db.performance_test.updateMany({test: true}, {\$set: {updated: true}});
// Delete test
db.performance_test.deleteMany({test: true});
var end = new Date();
var duration = end - start;
print('Performance test completed in ' + duration + 'ms');
print('Operations: 100 inserts, 1 count, 100 updates, 100 deletes');
if (duration < 5000) {
print('PERFORMANCE_TEST_OK');
} else {
print('PERFORMANCE_TEST_SLOW');
}
} catch (e) {
print('PERFORMANCE_TEST_FAILED: ' + e.message);
}
" 2>/dev/null || echo "PERFORMANCE_TEST_FAILED")
if [[ "$perf_test" == *"PERFORMANCE_TEST_OK"* ]]; then
echo "$perf_test" | grep -v "PERFORMANCE_TEST_OK"
log_success "Performance test passed"
return 0
elif [[ "$perf_test" == *"PERFORMANCE_TEST_SLOW"* ]]; then
echo "$perf_test" | grep -v "PERFORMANCE_TEST_SLOW"
log_warning "Performance test completed but was slow"
return 0
else
log_error "Performance test failed: $perf_test"
return 1
fi
}
# Display connection summary
display_summary() {
echo ""
log_info "MongoDB Connection Test Summary"
echo "=================================================="
echo "🏠 Host: $MONGODB_HOST:$MONGODB_PORT"
echo "🗄️ Database: $DATABASE_NAME"
echo "🔗 Connection String: $CONNECTION_STRING"
echo "⏱️ Timeout: ${TIMEOUT}s"
echo "📊 Tests completed: $(date)"
echo "=================================================="
}
# Main test function
main() {
log_info "Starting MongoDB connection tests for Release environment"
local test_results=()
local overall_success=true
# Run all tests
if test_tcp_connection; then
test_results+=("✅ TCP Connection")
else
test_results+=("❌ TCP Connection")
overall_success=false
fi
if test_mongodb_with_mongosh; then
test_results+=("✅ MongoDB Shell")
elif test_mongodb_with_docker; then
test_results+=("✅ MongoDB Docker")
elif test_mongodb_with_python; then
test_results+=("✅ MongoDB Python")
else
test_results+=("❌ MongoDB Client")
overall_success=false
fi
if test_from_application_container; then
test_results+=("✅ Application Container")
else
test_results+=("⚠️ Application Container")
fi
if check_mongodb_status; then
test_results+=("✅ Server Status")
else
test_results+=("⚠️ Server Status")
fi
if test_bcards_collections; then
test_results+=("✅ BCards Collections")
else
test_results+=("⚠️ BCards Collections")
fi
if test_mongodb_performance; then
test_results+=("✅ Performance Test")
else
test_results+=("⚠️ Performance Test")
fi
# Display results
display_summary
echo ""
log_info "Test Results:"
for result in "${test_results[@]}"; do
echo " $result"
done
echo ""
if [ "$overall_success" = true ]; then
log_success "All critical MongoDB tests passed!"
exit 0
else
log_error "Some critical MongoDB tests failed!"
exit 1
fi
}
# Run main function
main "$@"

View File

@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Configurations>Debug;Release;Testing</Configurations>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
<PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Testing.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using BCards.Web.Configuration;
using BCards.Web.Services;
using Testcontainers.MongoDb;
using Xunit;
namespace BCards.IntegrationTests.Fixtures;
public class BCardsWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder()
.WithImage("mongo:7.0")
.WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test")
.Build();
public IMongoDatabase TestDatabase { get; private set; } = null!;
public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}";
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, config) =>
{
// Remove existing configuration and add test configuration
config.Sources.Clear();
config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false);
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(),
["MongoDb:DatabaseName"] = TestDatabaseName,
["ASPNETCORE_ENVIRONMENT"] = "Testing"
});
});
builder.ConfigureServices(services =>
{
// Remove existing MongoDB services
services.RemoveAll(typeof(IMongoClient));
services.RemoveAll(typeof(IMongoDatabase));
// Add test MongoDB services
services.AddSingleton<IMongoClient>(serviceProvider =>
{
return new MongoClient(_mongoContainer.GetConnectionString());
});
services.AddScoped(serviceProvider =>
{
var client = serviceProvider.GetRequiredService<IMongoClient>();
TestDatabase = client.GetDatabase(TestDatabaseName);
return TestDatabase;
});
// Override Stripe settings for testing
services.Configure<StripeSettings>(options =>
{
options.PublishableKey = "pk_test_51234567890abcdef";
options.SecretKey = "sk_test_51234567890abcdef";
options.WebhookSecret = "whsec_test_1234567890abcdef";
});
// Mock external services that we don't want to test
services.RemoveAll(typeof(IEmailService));
services.AddScoped<IEmailService, MockEmailService>();
});
builder.UseEnvironment("Testing");
// Reduce logging noise during tests
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Warning);
logging.AddFilter("BCards", LogLevel.Information);
});
}
public async Task InitializeAsync()
{
await _mongoContainer.StartAsync();
}
public new async Task DisposeAsync()
{
await _mongoContainer.DisposeAsync();
await base.DisposeAsync();
}
public async Task CleanDatabaseAsync()
{
if (TestDatabase != null)
{
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
foreach (var collectionName in collections)
{
try
{
await TestDatabase.DropCollectionAsync(collectionName);
}
catch (Exception)
{
// Ignore errors if collection doesn't exist
}
}
}
}
}
// Mock email service to avoid external dependencies in tests
public class MockEmailService : IEmailService
{
public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null)
{
return Task.CompletedTask;
}
public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName)
{
return Task.CompletedTask;
}
public Task<bool> SendEmailAsync(string to, string subject, string htmlContent)
{
return Task.FromResult(true);
}
}

View File

@ -0,0 +1,182 @@
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.ViewModels;
namespace BCards.IntegrationTests.Fixtures;
public class MongoDbTestFixture
{
public IMongoDatabase Database { get; }
public IUserRepository UserRepository { get; }
public IUserPageRepository UserPageRepository { get; }
public ICategoryRepository CategoryRepository { get; }
public MongoDbTestFixture(IMongoDatabase database)
{
Database = database;
UserRepository = new UserRepository(database);
UserPageRepository = new UserPageRepository(database);
CategoryRepository = new CategoryRepository(database);
}
public async Task InitializeTestDataAsync()
{
// Initialize test categories
var categories = new List<Category>
{
new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" },
new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" },
new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" },
new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" }
};
var existingCategories = await CategoryRepository.GetAllActiveAsync();
if (!existingCategories.Any())
{
foreach (var category in categories)
{
await CategoryRepository.CreateAsync(category);
}
}
}
public async Task<User> CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null)
{
var user = new User
{
Id = Guid.NewGuid().ToString(),
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
Name = name ?? "Test User",
CurrentPlan = planType.ToString(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsActive = true
};
await UserRepository.CreateAsync(user);
return user;
}
public async Task<UserPage> CreateTestUserPageAsync(
string userId,
PageStatus status = PageStatus.Creating,
string category = "tecnologia",
int normalLinkCount = 3,
int productLinkCount = 1,
string? slug = null)
{
var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}";
var userPage = new UserPage
{
Id = Guid.NewGuid().ToString(),
UserId = userId,
DisplayName = "Test Page",
Category = category,
Slug = pageSlug,
Bio = "Test page for integration testing",
Status = status,
BusinessType = "individual",
Theme = new PageTheme { Name = "minimalist" },
Links = new List<LinkItem>(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ModerationAttempts = 0,
ModerationHistory = new List<ModerationHistory>()
};
// Generate preview token for non-Active pages
if (status != PageStatus.Active)
{
userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16];
userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4);
}
// Add normal links
for (int i = 0; i < normalLinkCount; i++)
{
userPage.Links.Add(new LinkItem
{
Title = $"Test Link {i + 1}",
Url = $"https://example.com/link{i + 1}",
Description = $"Description for test link {i + 1}",
Icon = "fas fa-link",
IsActive = true,
Order = i,
Type = LinkType.Normal
});
}
// Add product links
for (int i = 0; i < productLinkCount; i++)
{
userPage.Links.Add(new LinkItem
{
Title = $"Test Product {i + 1}",
Url = $"https://example.com/product{i + 1}",
Description = $"Description for test product {i + 1}",
Icon = "fas fa-shopping-cart",
IsActive = true,
Order = normalLinkCount + i,
Type = LinkType.Product,
ProductTitle = $"Amazing Product {i + 1}",
ProductPrice = "R$ 99,90",
ProductDescription = $"This is an amazing product for testing purposes {i + 1}",
ProductImage = $"https://example.com/images/product{i + 1}.jpg"
});
}
await UserPageRepository.CreateAsync(userPage);
return userPage;
}
public async Task<User> CreateTestUserWithPageAsync(
PlanType planType = PlanType.Basic,
PageStatus pageStatus = PageStatus.Creating,
int normalLinks = 3,
int productLinks = 1)
{
var user = await CreateTestUserAsync(planType);
var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks);
return user;
}
public async Task CleanAllDataAsync()
{
var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" };
foreach (var collectionName in collections)
{
try
{
await Database.DropCollectionAsync(collectionName);
}
catch (Exception)
{
// Ignore errors if collection doesn't exist
}
}
await InitializeTestDataAsync();
}
public async Task<List<UserPage>> GetUserPagesAsync(string userId)
{
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
var pages = await UserPageRepository.GetManyAsync(filter);
return pages.ToList();
}
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
{
var filter = Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(p => p.Category, category),
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
);
var pages = await UserPageRepository.GetManyAsync(filter);
return pages.FirstOrDefault();
}
}

View File

@ -0,0 +1,92 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using BCards.Web.Models;
namespace BCards.IntegrationTests.Helpers;
public static class AuthenticationHelper
{
public static Task<HttpClient> CreateAuthenticatedClientAsync(
WebApplicationFactory<Program> factory,
User testUser)
{
var client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
"Test", options => { });
});
}).CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Set the test user in headers for the TestAuthenticationHandler
client.DefaultRequestHeaders.Add("TestUserId", testUser.Id);
client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email);
client.DefaultRequestHeaders.Add("TestUserName", testUser.Name);
return Task.FromResult(client);
}
public static ClaimsPrincipal CreateTestClaimsPrincipal(User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Email, user.Email),
new(ClaimTypes.Name, user.Name),
new("sub", user.Id),
new("email", user.Email),
new("name", user.Name)
};
var identity = new ClaimsIdentity(claims, "Test");
return new ClaimsPrincipal(identity);
}
}
public class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var userId = Context.Request.Headers["TestUserId"].FirstOrDefault();
var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault();
var userName = Context.Request.Headers["TestUserName"].FirstOrDefault();
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail))
{
return Task.FromResult(AuthenticateResult.Fail("No test user provided"));
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Email, userEmail),
new(ClaimTypes.Name, userName ?? "Test User"),
new("sub", userId),
new("email", userEmail),
new("name", userName ?? "Test User")
};
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@ -0,0 +1,195 @@
using PuppeteerSharp;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BCards.IntegrationTests.Helpers;
public class PuppeteerTestHelper : IAsyncDisposable
{
private IBrowser? _browser;
private IPage? _page;
private readonly string _baseUrl;
public PuppeteerTestHelper(WebApplicationFactory<Program> factory)
{
_baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178";
}
public async Task InitializeAsync()
{
// Download Chrome if not available
await new BrowserFetcher().DownloadAsync();
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true, // Set to false for debugging
Args = new[]
{
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-web-security",
"--allow-running-insecure-content",
"--ignore-certificate-errors"
}
});
_page = await _browser.NewPageAsync();
// Set viewport for consistent testing
await _page.SetViewportAsync(new ViewPortOptions
{
Width = 1920,
Height = 1080
});
}
public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first.");
public async Task NavigateToAsync(string relativeUrl)
{
var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString();
await Page.GoToAsync(fullUrl, new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
}
public async Task<string> GetPageContentAsync()
{
return await Page.GetContentAsync();
}
public async Task<string> GetPageTitleAsync()
{
return await Page.GetTitleAsync();
}
public async Task<bool> ElementExistsAsync(string selector)
{
try
{
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Timeout = 5000
});
return true;
}
catch (WaitTaskTimeoutException)
{
return false;
}
}
public async Task ClickAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
await Page.ClickAsync(selector);
}
public async Task TypeAsync(string selector, string text)
{
await Page.WaitForSelectorAsync(selector);
await Page.TypeAsync(selector, text);
}
public async Task FillFormAsync(Dictionary<string, string> formData)
{
foreach (var kvp in formData)
{
await Page.WaitForSelectorAsync(kvp.Key);
await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''");
await Page.TypeAsync(kvp.Key, kvp.Value);
}
}
public async Task SubmitFormAsync(string formSelector)
{
await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']");
}
public async Task WaitForNavigationAsync()
{
await Page.WaitForNavigationAsync(new NavigationOptions
{
WaitUntil = new[] { WaitUntilNavigation.Networkidle0 }
});
}
public async Task WaitForElementAsync(string selector, int timeoutMs = 10000)
{
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Timeout = timeoutMs
});
}
public async Task<string> GetElementTextAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
var element = await Page.QuerySelectorAsync(selector);
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
return text?.Trim() ?? string.Empty;
}
public async Task<string> GetElementValueAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
var element = await Page.QuerySelectorAsync(selector);
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
return value ?? string.Empty;
}
public async Task<bool> IsElementVisibleAsync(string selector)
{
try
{
await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions
{
Visible = true,
Timeout = 2000
});
return true;
}
catch (WaitTaskTimeoutException)
{
return false;
}
}
public async Task TakeScreenshotAsync(string fileName)
{
await Page.ScreenshotAsync(fileName);
}
public Task<string> GetCurrentUrlAsync()
{
return Task.FromResult(Page.Url);
}
public async Task<List<string>> GetAllElementTextsAsync(string selector)
{
var elements = await Page.QuerySelectorAllAsync(selector);
var texts = new List<string>();
foreach (var element in elements)
{
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
texts.Add(text?.Trim() ?? string.Empty);
}
return texts;
}
public async ValueTask DisposeAsync()
{
if (_page != null)
{
await _page.CloseAsync();
}
if (_browser != null)
{
await _browser.CloseAsync();
}
}
}

View File

@ -0,0 +1,157 @@
# BCards Integration Tests
Este projeto contém testes integrados para o sistema BCards, validando workflows completos desde a criação de páginas até o sistema de moderação.
## Estrutura dos Testes
### Fixtures
- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container
- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB
- **StripeTestFixture**: Mock para integração Stripe (futuro)
### Helpers
- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft)
- **PuppeteerTestHelper**: Automação de browser para testes E2E
- **TestDataBuilder**: Builders para criar objetos de teste (futuro)
### Tests
- **PageCreationTests**: Validação de criação de páginas e limites por plano
- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas
- **ModerationWorkflowTests**: Workflow completo de moderação
- **PlanLimitationTests**: Validação de limitações por plano (futuro)
- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro)
## Cenários Testados
### Sistema de Páginas
1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.)
2. **Status de páginas**: Creating → PendingModeration → Active/Rejected
3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade)
4. **Validação de limites**: Links normais vs produto por plano
### Workflow de Moderação
1. **Submissão para moderação**: Creating → PendingModeration
2. **Aprovação**: PendingModeration → Active (page vira pública)
3. **Rejeição**: PendingModeration → Inactive/Rejected
4. **Preview system**: Acesso via token para pages não-Active
### Plan Limitations (Basic vs Professional)
- **Basic**: 5 links máximo
- **Professional**: 15 links máximo
- **Trial**: 1 página, 3 links + 1 produto
## Tecnologias Utilizadas
- **xUnit**: Framework de testes
- **FluentAssertions**: Assertions expressivas
- **WebApplicationFactory**: Testes integrados ASP.NET Core
- **Testcontainers**: MongoDB container para isolamento
- **PuppeteerSharp**: Automação de browser (Chrome)
- **MongoDB.Driver**: Acesso direto ao banco para validações
## Configuração
### Pré-requisitos
- .NET 8 SDK
- Docker (para MongoDB container)
- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp)
### Executar Testes
```bash
# Todos os testes
dotnet test src/BCards.IntegrationTests/
# Testes específicos
dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests"
dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests"
```
### Configuração Manual (MongoDB local)
Se preferir usar MongoDB local em vez do container:
```json
// appsettings.Testing.json
{
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB_Test"
}
}
```
## Estrutura de Dados de Teste
### User
- **Trial**: 1 página máx, links limitados
- **Basic**: 3 páginas, 5 links por página
- **Professional**: 5 páginas, 15 links por página
### UserPage
- **Status**: Creating, PendingModeration, Active, Rejected
- **Preview Tokens**: 4h de validade para access não-Active
- **Links**: Normal vs Product (limites diferentes por plano)
### Categories
- **tecnologia**: Empresas de tech
- **negocios**: Empresas e empreendedores
- **pessoal**: Freelancers e páginas pessoais
- **saude**: Profissionais da área da saúde
## Padrões de Teste
### Arrange-Act-Assert
Todos os testes seguem o padrão AAA:
```csharp
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating);
// Act
var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
```
### Cleanup Automático
- Cada teste usa database isolada (GUID no nome)
- Container MongoDB é destruído após os testes
- Sem interferência entre testes
### Mocks
- **EmailService**: Mockado para evitar envios reais
- **StripeService**: Mockado para evitar cobrança real
- **OAuth**: Mockado para evitar dependência externa
## Debug e Troubleshooting
### PuppeteerSharp
Para debug visual dos testes de browser:
```csharp
_browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = false, // Mostra o browser
SlowMo = 100 // Delay entre ações
});
```
### MongoDB
Para inspecionar dados durante testes, conecte no container:
```bash
docker exec -it <container-id> mongosh BCardsDB_Test
```
### Logs
Logs são configurados para mostrar apenas warnings/errors durante testes.
Para debug detalhado, altere em `BCardsWebApplicationFactory`:
```csharp
logging.SetMinimumLevel(LogLevel.Information);
```
## Próximos Passos
1. **PlanLimitationTests**: Validar todas as limitações por plano
2. **StripeIntegrationTests**: Testar upgrades via webhook
3. **PerformanceTests**: Testar carga no sistema de moderação
4. **E2E Tests**: Testes completos com PuppeteerSharp
5. **TrialExpirationTests**: Validar exclusão automática após 7 dias

View File

@ -0,0 +1,204 @@
using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.Web.Services;
using BCards.IntegrationTests.Fixtures;
using BCards.IntegrationTests.Helpers;
using System.Net;
namespace BCards.IntegrationTests.Tests;
public class ModerationWorkflowTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
{
private readonly BCardsWebApplicationFactory _factory;
private readonly HttpClient _client;
private MongoDbTestFixture _dbFixture = null!;
public ModerationWorkflowTests(BCardsWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
_dbFixture = new MongoDbTestFixture(database);
await _factory.CleanDatabaseAsync();
await _dbFixture.InitializeTestDataAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Submit page for moderation
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
// Verify page status changed in database
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.PendingModeration);
updatedPage.ModerationAttempts.Should().BeGreaterThan(0);
}
[Fact]
public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act
var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
// Verify page status didn't change
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.Creating);
}
[Fact]
public async Task ApprovePage_ShouldChangeStatusToActive()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
// Act - Approve the page
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good");
// Assert
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.Active);
updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
updatedPage.ModerationHistory.Should().HaveCount(1);
updatedPage.ModerationHistory.First().Status.Should().Be("approved");
}
[Fact]
public async Task RejectPage_ShouldChangeStatusToRejected()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
// Act - Reject the page
await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List<string> { "spam", "offensive" });
// Assert
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive
updatedPage.ModerationHistory.Should().HaveCount(1);
var rejectionHistory = updatedPage.ModerationHistory.First();
rejectionHistory.Status.Should().Be("rejected");
rejectionHistory.Reason.Should().Be("Inappropriate content");
rejectionHistory.Issues.Should().Contain("spam");
rejectionHistory.Issues.Should().Contain("offensive");
}
[Fact]
public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
// Approve the page
await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved");
// Act - Access the page without preview token
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner
}
[Fact]
public async Task GetPendingModerationPages_ShouldReturnCorrectPages()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com");
var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com");
// Create pages in different statuses
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2);
await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear
await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear
// Act
var pendingPages = await moderationService.GetPendingModerationAsync();
// Assert
pendingPages.Should().HaveCount(2);
pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration);
}
[Fact]
public async Task ModerationStats_ShouldReturnCorrectCounts()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var moderationService = scope.ServiceProvider.GetRequiredService<IModerationService>();
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
// Create pages with different statuses
var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1);
var pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1);
var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1);
// Approve one page today
await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good");
// Act
var stats = await moderationService.GetModerationStatsAsync();
// Assert
stats["pending"].Should().Be(2);
stats["approvedToday"].Should().Be(1);
stats["rejectedToday"].Should().Be(0);
}
}

View File

@ -0,0 +1,238 @@
using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.IntegrationTests.Fixtures;
using BCards.IntegrationTests.Helpers;
using System.Net.Http.Json;
namespace BCards.IntegrationTests.Tests;
public class PageCreationTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
{
private readonly BCardsWebApplicationFactory _factory;
private readonly HttpClient _client;
private MongoDbTestFixture _dbFixture = null!;
public PageCreationTests(BCardsWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
_dbFixture = new MongoDbTestFixture(database);
await _factory.CleanDatabaseAsync();
await _dbFixture.InitializeTestDataAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Create a page with 5 links (should succeed)
var pageData = new
{
DisplayName = "Test Business Page",
Category = "tecnologia",
BusinessType = "company",
Bio = "A test business page",
Slug = "test-business",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" },
new { Title = "Email", Url = "mailto:contact@example.com", Description = "Contact email", Icon = "fas fa-envelope" },
new { Title = "Phone", Url = "tel:+5511999999999", Description = "Contact phone", Icon = "fas fa-phone" },
new { Title = "LinkedIn", Url = "https://linkedin.com/company/example", Description = "LinkedIn profile", Icon = "fab fa-linkedin" },
new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" }
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links");
// Verify page was created in database
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
createdPages.Should().HaveCount(1);
var createdPage = createdPages.First();
createdPage.DisplayName.Should().Be("Test Business Page");
createdPage.Category.Should().Be("tecnologia");
createdPage.Status.Should().Be(PageStatus.Creating);
createdPage.Links.Should().HaveCount(5);
}
[Fact]
public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Try to create a page with 6 links (should fail for Basic plan)
var pageData = new
{
DisplayName = "Test Page Exceeding Limits",
Category = "tecnologia",
BusinessType = "individual",
Bio = "A test page with too many links",
Slug = "test-exceeding",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" },
new { Title = "Link 2", Url = "https://example2.com", Description = "Link 2", Icon = "fas fa-link" },
new { Title = "Link 3", Url = "https://example3.com", Description = "Link 3", Icon = "fas fa-link" },
new { Title = "Link 4", Url = "https://example4.com", Description = "Link 4", Icon = "fas fa-link" },
new { Title = "Link 5", Url = "https://example5.com", Description = "Link 5", Icon = "fas fa-link" },
new { Title = "Link 6", Url = "https://example6.com", Description = "Link 6", Icon = "fas fa-link" } // This should fail
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links");
// Verify no page was created
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
createdPages.Should().BeEmpty();
}
[Fact]
public async Task CreatePage_ShouldStartInCreatingStatus()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act
var pageData = new
{
DisplayName = "New Page",
Category = "pessoal",
BusinessType = "individual",
Bio = "Test page bio",
Slug = "new-page",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" }
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeTrue();
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
var page = createdPages.First();
page.Status.Should().Be(PageStatus.Creating);
page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens");
page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours");
}
[Fact]
public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Act - Create first page (should succeed)
var firstPageData = new
{
DisplayName = "First Trial Page",
Category = "pessoal",
BusinessType = "individual",
Bio = "First page in trial",
Slug = "first-trial",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" }
}
};
var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData);
firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page");
// Act - Try to create second page (should fail)
var secondPageData = new
{
DisplayName = "Second Trial Page",
Category = "tecnologia",
BusinessType = "individual",
Bio = "Second page in trial - should fail",
Slug = "second-trial",
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" }
}
};
var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData);
// Assert
secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page");
var createdPages = await _dbFixture.GetUserPagesAsync(user.Id);
createdPages.Should().HaveCount(1, "Trial should only have one page");
}
[Fact]
public async Task CreatePage_ShouldGenerateUniqueSlug()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Create first page with specific slug
await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-slug");
// Act - Try to create another page with same name (should get different slug)
var pageData = new
{
DisplayName = "Test Page", // Same display name, should generate different slug
Category = "tecnologia",
BusinessType = "individual",
Bio = "Another test page",
Slug = "test-slug", // Try to use same slug
SelectedTheme = "minimalist",
Links = new[]
{
new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" }
}
};
var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData);
// Assert
createResponse.IsSuccessStatusCode.Should().BeTrue();
var userPages = await _dbFixture.GetUserPagesAsync(user.Id);
userPages.Should().HaveCount(2);
var slugs = userPages.Select(p => p.Slug).ToList();
slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs");
slugs.Should().Contain("test-slug");
slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page");
}
}

View File

@ -0,0 +1,240 @@
using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.ViewModels;
using BCards.IntegrationTests.Fixtures;
using BCards.IntegrationTests.Helpers;
using System.Net;
namespace BCards.IntegrationTests.Tests;
public class PreviewTokenTests : IClassFixture<BCardsWebApplicationFactory>, IAsyncLifetime
{
private readonly BCardsWebApplicationFactory _factory;
private readonly HttpClient _client;
private MongoDbTestFixture _dbFixture = null!;
public PreviewTokenTests(BCardsWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
_dbFixture = new MongoDbTestFixture(database);
await _factory.CleanDatabaseAsync();
await _dbFixture.InitializeTestDataAsync();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown
}
[Fact]
public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
}
[Fact]
public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
}
[Fact]
public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
// Simulate expired token
page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago
await _dbFixture.UserPageRepository.UpdateAsync(page);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain("Página em desenvolvimento. Acesso restrito.");
}
[Fact]
public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
var oldToken = page.PreviewToken;
// Act
var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
var jsonResponse = await response.Content.ReadAsStringAsync();
jsonResponse.Should().Contain("success");
jsonResponse.Should().Contain("previewToken");
// Verify new token is different and works
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.PreviewToken.Should().NotBe(oldToken);
updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3));
// Test new token works
var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}");
pageResponse.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail()
{
// Arrange
var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com");
var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@example.com");
var page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1);
var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser);
// Act
var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeFalse();
}
[Theory]
[InlineData(PageStatus.Creating)]
[InlineData(PageStatus.PendingModeration)]
[InlineData(PageStatus.Rejected)]
public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status)
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status");
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
}
[Theory]
[InlineData(PageStatus.Creating)]
[InlineData(PageStatus.PendingModeration)]
[InlineData(PageStatus.Rejected)]
public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status)
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status");
}
[Fact]
public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1);
// Act
var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var content = await response.Content.ReadAsStringAsync();
content.Should().Contain(page.DisplayName);
content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages
}
[Fact]
public async Task RefreshPreviewToken_ShouldExtendExpiry()
{
// Arrange
var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic);
var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1);
var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user);
// Make token close to expiry
page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30);
await _dbFixture.UserPageRepository.UpdateAsync(page);
var oldExpiry = page.PreviewTokenExpiry;
// Act
var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null);
// Assert
response.IsSuccessStatusCode.Should().BeTrue();
var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id);
var updatedPage = updatedPages.First();
updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3));
updatedPage.PreviewToken.Should().NotBeNullOrEmpty();
}
}

View File

@ -0,0 +1,43 @@
{
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test"
},
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "BCardsDB_Test"
},
"Stripe": {
"PublishableKey": "pk_test_51234567890abcdef",
"SecretKey": "sk_test_51234567890abcdef",
"WebhookSecret": "whsec_test_1234567890abcdef"
},
"Authentication": {
"Google": {
"ClientId": "test-google-client-id.apps.googleusercontent.com",
"ClientSecret": "GOCSPX-test-google-client-secret"
},
"Microsoft": {
"ClientId": "test-microsoft-client-id",
"ClientSecret": "test-microsoft-client-secret"
}
},
"SendGrid": {
"ApiKey": "SG.test-sendgrid-api-key"
},
"Moderation": {
"RequireApproval": true,
"AuthKey": "test-moderation-auth-key",
"MaxPendingPages": 100,
"MaxRejectionsBeforeBan": 3
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"BCards": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ASPNETCORE_ENVIRONMENT": "Testing"
}

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

@ -5,11 +5,14 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultEmbeddedResourceItems>false</EnableDefaultEmbeddedResourceItems>
<RuntimeIdentifiers>linux-x64;linux-arm64</RuntimeIdentifiers>
<Configurations>Debug;Release;Testing</Configurations>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="Stripe.net" Version="44.7.0" />
<PackageReference Include="Markdig" Version="0.43.0" />
<PackageReference Include="MongoDB.Driver" Version="3.4.2" />
<PackageReference Include="Stripe.net" Version="48.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.2.0" />
@ -19,10 +22,31 @@
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="1.3.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" />
<PackageReference Include="AspNetCore.DataProtection.MongoDB" Version="8.0.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\**\*.resx" />
</ItemGroup>
<ItemGroup>
<None Update="Content\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)'=='Testing'">
<DefineConstants>$(DefineConstants);TESTING</DefineConstants>
</PropertyGroup>
</Project>

View File

@ -5,4 +5,8 @@ public class StripeSettings
public string PublishableKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
public string Environment { get; set; } = "test";
public bool IsTestMode => Environment.ToLowerInvariant() == "test";
public bool IsLiveMode => Environment.ToLowerInvariant() == "live";
}

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,63 @@
# Regras de Moderação e Diretrizes da Comunidade BCards
**Última atualização:** 31 de agosto de 2025
O BCards se dedica a manter uma comunidade segura, profissional e respeitosa. Todas as páginas e conteúdos criados em nossa plataforma devem seguir estas regras. O descumprimento pode levar à remoção do conteúdo, suspensão da página ou banimento da conta.
---
### 1. Conteúdo Estritamente Proibido
O seguinte conteúdo será removido imediatamente e pode resultar no banimento da conta sem aviso prévio:
- **Conteúdo Adulto e Sexual Explícito:**
- Nudez, pornografia, ou qualquer conteúdo sexualmente sugestivo.
- Serviços de acompanhantes, prostituição ou conteúdo relacionado.
- **Violência, Ódio e Discriminação:**
- Ameaças diretas ou indiretas de violência contra indivíduos ou grupos.
- Discurso de ódio baseado em raça, etnia, religião, nacionalidade, gênero, orientação sexual, deficiência ou qualquer outra característica protegida.
- Glorificação de violência, terrorismo ou organizações extremistas.
- **Atividades Ilegais:**
- Venda, promoção ou facilitação de drogas ilegais, substâncias controladas e armas de fogo.
- Promoção de qualquer atividade ilegal, como jogos de azar não regulamentados, esquemas de pirâmide ou fraude.
- **Spam, Phishing e Golpes:**
- Links que levam a sites maliciosos, phishing ou que tentam enganar os usuários para obter informações pessoais.
- Páginas criadas com o único propósito de gerar tráfego de spam ou manipular SEO.
- **Violação de Direitos Autorais e Propriedade Intelectual:**
- Uso não autorizado de marcas registradas, logotipos, ou material protegido por direitos autorais.
- Venda ou distribuição de conteúdo pirateado.
- **Informações Falsas e Desinformação Perigosa:**
- Disseminação de informações comprovadamente falsas que possam causar dano real (ex: desinformação médica perigosa, teorias da conspiração violentas).
- **Conteúdo que Explora ou Prejudica Menores:**
- Qualquer conteúdo que explore, sexualize ou coloque menores em risco. Denunciaremos tal conteúdo às autoridades competentes.
---
### 2. Conteúdo Permitido com Restrições
Este conteúdo pode ser permitido, mas está sujeito a uma análise mais rigorosa e deve cumprir todas as leis locais aplicáveis:
- **Bebidas Alcoólicas:** Permitido apenas se o criador da página tiver a licença apropriada para vender e se o conteúdo for direcionado a um público adulto, com as devidas restrições de idade.
- **Jogos e Apostas:** Permitido apenas em jurisdições onde tal atividade é legal e regulamentada. A página deve exibir claramente as licenças e avisos legais necessários.
- **Conteúdo Político:** Permitido, desde que não incite ódio, violência ou extremismo. Não é permitido o uso da plataforma para campanhas de desinformação.
- **Produtos ou Serviços Financeiros:** Deve cumprir todas as regulamentações financeiras locais e ser transparente sobre riscos.
---
### 3. Critérios de Aprovação e Boas Práticas
Para que sua página seja aprovada e tenha um bom desempenho, siga estas diretrizes:
- **Links Funcionais:** Todos os links devem estar funcionando e levar ao destino prometido.
- **Informações Claras e Verdadeiras:** A biografia, títulos e descrições devem representar com precisão o propósito da sua página.
- **Imagens Apropriadas:** As imagens de perfil e de fundo devem ser de alta qualidade e não violar nenhuma das regras acima.
- **Idioma:** O conteúdo principal deve estar em português ou espanhol, de acordo com o público-alvo.
- **Respeito às Leis:** O conteúdo da sua página deve respeitar todas as leis e regulamentações do Brasil e dos países onde você atua.
Nosso objetivo é capacitar criadores e profissionais. Estas regras nos ajudam a garantir que o BCards continue sendo uma plataforma confiável e valiosa para todos.

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

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,9 @@ using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
#if TESTING
using BCards.Web.TestSupport;
#endif
namespace BCards.Web.Controllers;
@ -13,24 +16,106 @@ namespace BCards.Web.Controllers;
public class AuthController : Controller
{
private readonly IAuthService _authService;
private readonly IOAuthHealthService _oauthHealthService;
private readonly ILogger<AuthController> _logger;
private readonly IWebHostEnvironment _env;
public AuthController(IAuthService authService)
public AuthController(
IAuthService authService,
IOAuthHealthService oauthHealthService,
ILogger<AuthController> logger,
IWebHostEnvironment env)
{
_authService = authService;
_oauthHealthService = oauthHealthService;
_logger = logger;
_env = env;
}
[HttpGet]
[Route("Login")]
public IActionResult Login(string? returnUrl = null)
public async Task<IActionResult> Login(string? returnUrl = null)
{
ViewBag.ReturnUrl = returnUrl;
ViewBag.IsTestingEnvironment = _env.IsEnvironment("Testing");
// Verificar status dos OAuth providers e passar para a view
var oauthStatus = await _oauthHealthService.CheckOAuthProvidersAsync();
ViewBag.OAuthStatus = oauthStatus;
return View();
}
/// <summary>
/// Endpoint AJAX para verificar status dos OAuth providers
/// </summary>
[HttpGet]
[Route("oauth-status")]
public async Task<IActionResult> GetOAuthStatus()
{
try
{
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
return Json(new
{
googleAvailable = status.GoogleAvailable,
microsoftAvailable = status.MicrosoftAvailable,
allProvidersHealthy = status.AllProvidersHealthy,
anyProviderHealthy = status.AnyProviderHealthy,
message = GetOAuthStatusMessage(status)
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Erro ao verificar status OAuth");
return Json(new { error = "Erro ao verificar disponibilidade de login" });
}
}
private string GetOAuthStatusMessage(OAuthHealthStatus status)
{
if (status.AllProvidersHealthy)
return "Todos os métodos de login estão funcionando normalmente";
if (!status.AnyProviderHealthy)
return "⚠️ Login temporariamente indisponível. Tente novamente em alguns minutos.";
var available = new List<string>();
var unavailable = new List<string>();
if (status.GoogleAvailable) available.Add("Google");
else unavailable.Add("Google");
if (status.MicrosoftAvailable) available.Add("Microsoft");
else unavailable.Add("Microsoft");
return $"⚠️ Login com {string.Join(" e ", unavailable)} temporariamente indisponível. Use {string.Join(" ou ", available)}.";
}
[HttpPost]
[Route("LoginWithGoogle")]
public IActionResult LoginWithGoogle(string? returnUrl = null)
public async Task<IActionResult> LoginWithGoogle(string? returnUrl = null)
{
// TEMPORARIAMENTE DESABILITADO - para testar se a verificação OAuth está causando problemas
/*
try
{
// Verificar se Google está disponível (com timeout curto para não travar UX)
var isGoogleAvailable = await _oauthHealthService.IsGoogleAvailableAsync();
if (!isGoogleAvailable)
{
_logger.LogWarning("🟡 Usuário tentou fazer login com Google, mas o serviço parece estar offline");
TempData["LoginError"] = "Login com Google pode estar temporariamente indisponível. Se o problema persistir, tente Microsoft.";
// Mas PERMITA o login mesmo assim - o próprio Google vai dar erro se estiver offline
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao verificar status Google - permitindo login mesmo assim");
// Se não conseguir verificar, permitir login
}
*/
var redirectUrl = Url.Action("GoogleCallback", "Auth", new { returnUrl });
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
return Challenge(properties, GoogleDefaults.AuthenticationScheme);
@ -38,13 +123,59 @@ public class AuthController : Controller
[HttpPost]
[Route("LoginWithMicrosoft")]
public IActionResult LoginWithMicrosoft(string? returnUrl = null)
public async Task<IActionResult> LoginWithMicrosoft(string? returnUrl = null)
{
// TEMPORARIAMENTE DESABILITADO - para testar se a verificação OAuth está causando problemas
/*
try
{
// Verificar se Microsoft está disponível (com timeout curto para não travar UX)
var isMicrosoftAvailable = await _oauthHealthService.IsMicrosoftAvailableAsync();
if (!isMicrosoftAvailable)
{
_logger.LogWarning("🟡 Usuário tentou fazer login com Microsoft, mas o serviço parece estar offline");
TempData["LoginError"] = "Login com Microsoft pode estar temporariamente indisponível. Se o problema persistir, tente Google.";
// Mas PERMITA o login mesmo assim - o próprio Microsoft vai dar erro se estiver offline
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Erro ao verificar status Microsoft - permitindo login mesmo assim");
// Se não conseguir verificar, permitir login
}
*/
var redirectUrl = Url.Action("MicrosoftCallback", "Auth", new { returnUrl });
var properties = new AuthenticationProperties { RedirectUri = redirectUrl };
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
}
#if TESTING
[HttpPost]
[Route("LoginWithTest")]
public IActionResult LoginWithTest(string? returnUrl = null)
{
if (!_env.IsEnvironment("Testing"))
{
return NotFound(); // Endpoint de teste só funciona no ambiente de Testing
}
string redirectUrlString;
if (Url.IsLocalUrl(returnUrl))
{
redirectUrlString = returnUrl;
}
else
{
redirectUrlString = Url.Action("Dashboard", "Admin");
}
var properties = new AuthenticationProperties { RedirectUri = redirectUrlString };
return Challenge(properties, TestAuthConstants.AuthenticationScheme);
}
#endif
[HttpGet]
[Route("GoogleCallback")]
public async Task<IActionResult> GoogleCallback(string? returnUrl = null)
@ -52,7 +183,7 @@ public class AuthController : Controller
var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
TempData["Error"] = "Falha na autenticação com Google";
TempData["Error"] = "Falha na autentica<EFBFBD><EFBFBD>o com Google";
return RedirectToAction("Login");
}
@ -82,7 +213,7 @@ public class AuthController : Controller
var result = await HttpContext.AuthenticateAsync(MicrosoftAccountDefaults.AuthenticationScheme);
if (!result.Succeeded)
{
TempData["Error"] = "Falha na autenticação com Microsoft";
TempData["Error"] = "Falha na autentica<EFBFBD><EFBFBD>o com Microsoft";
return RedirectToAction("Login");
}
@ -105,30 +236,15 @@ public class AuthController : Controller
return RedirectToLocal(returnUrl);
}
[HttpGet]
[HttpPost]
[Route("Logout")]
[Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout()
{
// Identifica qual provedor foi usado
var authResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var loginProvider = authResult.Principal?.FindFirst("LoginProvider")?.Value;
// Faz logout local primeiro
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
TempData["Success"] = "Logout realizado com sucesso";
// Se foi Microsoft, faz logout completo no provedor
if (loginProvider == "Microsoft")
{
return SignOut(MicrosoftAccountDefaults.AuthenticationScheme);
}
// Se foi Google, faz logout completo no provedor
else if (loginProvider == "Google")
{
return SignOut(GoogleDefaults.AuthenticationScheme);
}
TempData["Success"] = "Você saiu com segurança.";
return RedirectToAction("Index", "Home");
}

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

@ -0,0 +1,305 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
using System.Text.Json;
namespace BCards.Web.Controllers;
[ApiController]
[Route("health")]
public class HealthController : ControllerBase
{
private readonly HealthCheckService _healthCheckService;
private readonly ILogger<HealthController> _logger;
private readonly IConfiguration _configuration;
private static readonly DateTime _startTime = DateTime.UtcNow;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger, IConfiguration configuration)
{
_healthCheckService = healthCheckService;
_logger = logger;
_configuration = configuration;
}
/// <summary>
/// Health check simples - retorna apenas status geral
/// </summary>
[HttpGet]
public async Task<IActionResult> GetHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var response = new
{
status = healthReport.Status.ToString().ToLower(),
timestamp = DateTime.UtcNow,
duration = $"{stopwatch.ElapsedMilliseconds}ms"
};
_logger.LogInformation("Simple health check completed: {Status} in {Duration}ms",
response.status, stopwatch.ElapsedMilliseconds);
return healthReport.Status == HealthStatus.Healthy
? Ok(response)
: StatusCode(503, response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds);
return StatusCode(503, new
{
status = "unhealthy",
timestamp = DateTime.UtcNow,
duration = $"{stopwatch.ElapsedMilliseconds}ms",
error = ex.Message
});
}
}
/// <summary>
/// Health check detalhado - formato completo com métricas
/// </summary>
[HttpGet("detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var checks = new Dictionary<string, object>();
foreach (var entry in healthReport.Entries)
{
checks[entry.Key] = new
{
status = entry.Value.Status.ToString().ToLower(),
duration = entry.Value.Duration.TotalMilliseconds + "ms",
description = entry.Value.Description,
data = entry.Value.Data,
exception = entry.Value.Exception?.Message
};
}
var uptime = DateTime.UtcNow - _startTime;
var response = new
{
applicationName = _configuration["ApplicationName"] ?? "BCards",
status = healthReport.Status.ToString().ToLower(),
timestamp = DateTime.UtcNow,
uptime = FormatUptime(uptime),
totalDuration = $"{stopwatch.ElapsedMilliseconds}ms",
checks = checks,
version = "1.0.0",
environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"
};
_logger.LogInformation("Detailed health check completed: {Status} in {Duration}ms - {HealthyCount}/{TotalCount} services healthy",
response.status, stopwatch.ElapsedMilliseconds,
healthReport.Entries.Count(e => e.Value.Status == HealthStatus.Healthy),
healthReport.Entries.Count);
return healthReport.Status == HealthStatus.Unhealthy
? StatusCode(503, response)
: Ok(response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Detailed health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds);
return StatusCode(503, new
{
applicationName = "BCards",
status = "unhealthy",
timestamp = DateTime.UtcNow,
uptime = FormatUptime(DateTime.UtcNow - _startTime),
totalDuration = $"{stopwatch.ElapsedMilliseconds}ms",
error = ex.Message,
version = "1.0.0",
environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"
});
}
}
/// <summary>
/// Health check para Uptime Kuma - formato específico
/// </summary>
[HttpGet("uptime-kuma")]
public async Task<IActionResult> GetUptimeKumaHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var isHealthy = healthReport.Status == HealthStatus.Healthy;
var response = new
{
status = isHealthy ? "up" : "down",
message = isHealthy ? "All services operational" : $"Issues detected: {healthReport.Status}",
timestamp = DateTime.UtcNow.ToString("O"),
responseTime = stopwatch.ElapsedMilliseconds,
services = healthReport.Entries.ToDictionary(
e => e.Key,
e => new {
status = e.Value.Status.ToString().ToLower(),
responseTime = e.Value.Duration.TotalMilliseconds
}
)
};
_logger.LogInformation("Uptime Kuma health check: {Status} in {Duration}ms",
response.status, stopwatch.ElapsedMilliseconds);
var memoryMB = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 2);
var uptimeMinutes = (DateTime.UtcNow - Process.GetCurrentProcess().StartTime).TotalMinutes;
_logger.LogInformation("[HEALTH] Memory: {memoryBytes} MemoryMB: {memoryMB} ThreadPool: {threadPool} ProcessId: {processId} ActiveConnections: {activeConnections} UptimeMinutes: {uptimeMinutes}",
GC.GetTotalMemory(false),
memoryMB,
ThreadPool.ThreadCount,
Process.GetCurrentProcess().Id,
HttpContext.Connection?.Id ?? "null",
Math.Round(uptimeMinutes, 1));
return Ok(response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Uptime Kuma health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds);
return Ok(new
{
status = "down",
message = $"Health check failed: {ex.Message}",
timestamp = DateTime.UtcNow.ToString("O"),
responseTime = stopwatch.ElapsedMilliseconds
});
}
}
/// <summary>
/// Health checks específicos por serviço
/// </summary>
[HttpGet("mongodb")]
public async Task<IActionResult> GetMongoDbHealth()
{
return await GetSpecificServiceHealth("mongodb");
}
[HttpGet("stripe")]
public async Task<IActionResult> GetStripeHealth()
{
return await GetSpecificServiceHealth("stripe");
}
[HttpGet("sendgrid")]
public async Task<IActionResult> GetSendGridHealth()
{
return await GetSpecificServiceHealth("sendgrid");
}
[HttpGet("external")]
public async Task<IActionResult> GetExternalServicesHealth()
{
return await GetSpecificServiceHealth("external_services");
}
[HttpGet("resources")]
public async Task<IActionResult> GetSystemResourcesHealth()
{
return await GetSpecificServiceHealth("resources");
}
private async Task<IActionResult> GetSpecificServiceHealth(string serviceName)
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync(check => check.Name == serviceName);
stopwatch.Stop();
if (!healthReport.Entries.Any())
{
return NotFound(new { error = $"Service '{serviceName}' not found" });
}
var entry = healthReport.Entries.First().Value;
_logger.LogInformation("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} ResponseTime: {responseTime} Timestamp: {timestamp} Data: {data}",
serviceName,
entry.Status.ToString().ToLower(),
entry.Duration.TotalMilliseconds,
stopwatch.ElapsedMilliseconds,
DateTime.UtcNow,
entry.Data != null ? string.Join(", ", entry.Data.Select(d => $"{d.Key}:{d.Value}")) : "none");
if (entry.Exception != null)
{
_logger.LogError("[HEALTH_CHECK] Service: {serviceName} Status: {status} Duration: {duration} Error: {error} Exception: {exception}",
serviceName, entry.Status.ToString().ToLower(), stopwatch.ElapsedMilliseconds,
entry.Exception.Message, entry.Exception.ToString());
}
var response = new
{
service = serviceName,
status = entry.Status.ToString().ToLower(),
timestamp = DateTime.UtcNow,
duration = $"{entry.Duration.TotalMilliseconds}ms",
description = entry.Description,
data = entry.Data,
exception = entry.Exception?.Message
};
_logger.LogInformation("Service {ServiceName} health check: {Status} in {Duration}ms",
serviceName, response.status, entry.Duration.TotalMilliseconds);
return entry.Status == HealthStatus.Unhealthy
? StatusCode(503, response)
: Ok(response);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Service {ServiceName} health check failed after {Duration}ms",
serviceName, stopwatch.ElapsedMilliseconds);
return StatusCode(503, new
{
service = serviceName,
status = "unhealthy",
timestamp = DateTime.UtcNow,
duration = $"{stopwatch.ElapsedMilliseconds}ms",
error = ex.Message
});
}
}
private static string FormatUptime(TimeSpan uptime)
{
if (uptime.TotalDays >= 1)
return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalHours >= 1)
return $"{uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalMinutes >= 1)
return $"{uptime.Minutes}m {uptime.Seconds}s";
return $"{uptime.Seconds}s";
}
}

View File

@ -1,5 +1,7 @@
using BCards.Web.Services;
using BCards.Web.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace BCards.Web.Controllers;
@ -7,15 +9,31 @@ public class HomeController : Controller
{
private readonly ICategoryService _categoryService;
private readonly IUserPageService _userPageService;
private readonly StripeSettings _stripeSettings;
public HomeController(ICategoryService categoryService, IUserPageService userPageService)
public HomeController(
ICategoryService categoryService,
IUserPageService userPageService,
IOptions<StripeSettings> stripeSettings)
{
_categoryService = categoryService;
_userPageService = userPageService;
_stripeSettings = stripeSettings.Value;
}
public async Task<IActionResult> Index()
{
// Cache condicional: apenas para usuários não logados
if (User.Identity?.IsAuthenticated != true)
{
Response.Headers["Cache-Control"] = "public, max-age=600"; // 10 minutos
}
else
{
Response.Headers["Cache-Control"] = "no-cache, must-revalidate";
Response.Headers["Vary"] = "Cookie";
}
ViewBag.IsHomePage = true; // Flag para identificar home
ViewBag.Categories = await _categoryService.GetAllCategoriesAsync();
ViewBag.RecentPages = await _userPageService.GetRecentPagesAsync(6);
@ -23,6 +41,7 @@ public class HomeController : Controller
}
[Route("Privacy")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)] // 1 hora
public IActionResult Privacy()
{
ViewBag.IsHomePage = true;
@ -32,26 +51,55 @@ public class HomeController : Controller
[Route("Pricing")]
public IActionResult Pricing()
{
// Cache condicional: apenas para usuários não logados
if (User.Identity?.IsAuthenticated != true)
{
Response.Headers["Cache-Control"] = "public, max-age=1800"; // 30 minutos
}
else
{
Response.Headers["Cache-Control"] = "no-cache, must-revalidate";
Response.Headers["Vary"] = "Cookie";
}
ViewBag.IsHomePage = true;
return View();
}
[Route("categoria/{categorySlug}")]
public async Task<IActionResult> Category(string categorySlug)
[Route("health")]
public IActionResult Health()
{
ViewBag.IsHomePage = true;
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
if (category == null)
return NotFound();
var pages = await _userPageService.GetPagesByCategoryAsync(categorySlug, 20);
ViewBag.Category = category;
ViewBag.Pages = pages;
return View();
return Ok(new {
status = "healthy",
timestamp = DateTime.UtcNow,
version = "1.0.0"
});
}
[Route("stripe-info")]
public IActionResult StripeInfo()
{
// Apenas para usuários logados ou em desenvolvimento
if (!User.Identity?.IsAuthenticated == true && !HttpContext.RequestServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment())
{
return NotFound();
}
var info = new
{
Environment = _stripeSettings.Environment,
IsTestMode = _stripeSettings.IsTestMode,
IsLiveMode = _stripeSettings.IsLiveMode,
PublishableKeyPrefix = _stripeSettings.PublishableKey?.Substring(0, Math.Min(12, _stripeSettings.PublishableKey.Length)) + "...",
SecretKeyPrefix = _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "...",
WebhookConfigured = !string.IsNullOrEmpty(_stripeSettings.WebhookSecret),
Timestamp = DateTime.UtcNow
};
return Json(info);
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{

View File

@ -0,0 +1,202 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ImageController : ControllerBase
{
private readonly IImageStorageService _imageStorage;
private readonly ILogger<ImageController> _logger;
public ImageController(IImageStorageService imageStorage, ILogger<ImageController> logger)
{
_imageStorage = imageStorage;
_logger = logger;
}
[HttpGet("{imageId}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "imageId" })]
public async Task<IActionResult> GetImage(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
{
_logger.LogWarning("Image request with empty ID");
return BadRequest("Image ID is required");
}
var imageBytes = await _imageStorage.GetImageAsync(imageId);
if (imageBytes == null || imageBytes.Length == 0)
{
_logger.LogWarning("Image not found: {ImageId}", imageId);
return NotFound("Image not found");
}
// Headers de cache mais agressivos para imagens
Response.Headers["Cache-Control"] = "public, max-age=31536000"; // 1 ano
Response.Headers["Expires"] = DateTime.UtcNow.AddYears(1).ToString("R");
Response.Headers["ETag"] = $"\"{imageId}\"";
return File(imageBytes, "image/jpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving image: {ImageId}", imageId);
return NotFound("Image not found");
}
}
[HttpPost("upload")]
[RequestSizeLimit(2 * 1024 * 1024)] // 2MB máximo - otimizado para celulares
[DisableRequestSizeLimit] // Para formulários grandes
public async Task<IActionResult> UploadImage(IFormFile file)
{
try
{
if (file == null || file.Length == 0)
{
_logger.LogWarning("Upload request with no file");
return BadRequest(new { error = "No file provided", code = "NO_FILE" });
}
// Validações de tipo
var allowedTypes = new[] { "image/jpeg", "image/jpg", "image/png", "image/gif" };
if (!allowedTypes.Contains(file.ContentType.ToLower()))
{
_logger.LogWarning("Invalid file type uploaded: {ContentType}", file.ContentType);
return BadRequest(new {
error = "Invalid file type. Only JPEG, PNG and GIF are allowed.",
code = "INVALID_TYPE"
});
}
// Validação de tamanho
if (file.Length > 2 * 1024 * 1024) // 2MB
{
_logger.LogWarning("File too large: {Size}MB", file.Length / (1024 * 1024));
return BadRequest(new {
error = "Arquivo muito grande. Tamanho máximo: 2MB.",
code = "FILE_TOO_LARGE"
});
}
// Processar upload
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
// Validação adicional: verificar se é realmente uma imagem
if (!IsValidImageBytes(imageBytes))
{
_logger.LogWarning("Invalid image data uploaded");
return BadRequest(new {
error = "Invalid image data.",
code = "INVALID_IMAGE"
});
}
var imageId = await _imageStorage.SaveImageAsync(imageBytes, file.FileName, file.ContentType);
_logger.LogInformation("Image uploaded successfully: {ImageId}, Original: {FileName}, Size: {Size}KB",
imageId, file.FileName, file.Length / 1024);
return Ok(new {
success = true,
imageId,
url = $"/api/image/{imageId}",
originalSize = file.Length,
fileName = file.FileName
});
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "Invalid upload parameters");
return BadRequest(new {
error = ex.Message,
code = "VALIDATION_ERROR"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error uploading image: {FileName}", file?.FileName);
return StatusCode(500, new {
error = "Error uploading image. Please try again.",
code = "UPLOAD_ERROR"
});
}
}
[HttpDelete("{imageId}")]
public async Task<IActionResult> DeleteImage(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return BadRequest(new { error = "Image ID is required" });
var deleted = await _imageStorage.DeleteImageAsync(imageId);
if (!deleted)
return NotFound(new { error = "Image not found" });
_logger.LogInformation("Image deleted: {ImageId}", imageId);
return Ok(new { success = true, message = "Image deleted successfully" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting image: {ImageId}", imageId);
return StatusCode(500, new { error = "Error deleting image" });
}
}
[HttpHead("{imageId}")]
public async Task<IActionResult> ImageExists(string imageId)
{
try
{
if (string.IsNullOrEmpty(imageId))
return BadRequest();
var exists = await _imageStorage.ImageExistsAsync(imageId);
return exists ? Ok() : NotFound();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking image existence: {ImageId}", imageId);
return StatusCode(500);
}
}
private static bool IsValidImageBytes(byte[] bytes)
{
if (bytes == null || bytes.Length < 4)
return false;
// Verificar assinaturas de arquivos de imagem
var jpegSignature = new byte[] { 0xFF, 0xD8, 0xFF };
var pngSignature = new byte[] { 0x89, 0x50, 0x4E, 0x47 };
var gifSignature = new byte[] { 0x47, 0x49, 0x46 };
return StartsWithSignature(bytes, jpegSignature) ||
StartsWithSignature(bytes, pngSignature) ||
StartsWithSignature(bytes, gifSignature);
}
private static bool StartsWithSignature(byte[] bytes, byte[] signature)
{
if (bytes.Length < signature.Length)
return false;
for (int i = 0; i < signature.Length; i++)
{
if (bytes[i] != signature[i])
return false;
}
return true;
}
}

View File

@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Mvc;
using System.Linq;
namespace BCards.Web.Controllers
{
public class LegalController : Controller
{
// GET: /privacidade or /Legal/Privacy
public IActionResult Privacy()
{
var lang = GetUserLanguage();
if (lang == "es")
{
return RedirectToAction("PrivacyES");
}
return View();
}
// GET: /privacy or /Legal/PrivacyES
public IActionResult PrivacyES()
{
return View();
}
// GET: /termos or /Legal/Terms
public IActionResult Terms()
{
var lang = GetUserLanguage();
if (lang == "es")
{
return RedirectToAction("TermsES");
}
return View();
}
// GET: /terminos or /Legal/TermsES
public IActionResult TermsES()
{
return View();
}
// GET: /regras or /Legal/CommunityGuidelines
public IActionResult CommunityGuidelines()
{
// As regras da comunidade podem ser um único documento com tradução na própria página
// ou também podem ser redirecionadas. Por enquanto, uma única view.
return View();
}
// GET: /Legal/RequestData
public IActionResult RequestData()
{
ViewData["Title"] = "Solicitação de Dados Pessoais";
return View();
}
/// <summary>
/// Detecta o idioma do usuário com base em query string, cabeçalhos ou um padrão.
/// </summary>
/// <returns>"pt" para português ou "es" para espanhol.</returns>
private string GetUserLanguage()
{
// 1. Verificar por parâmetro na URL (?lang=es)
if (Request.Query.TryGetValue("lang", out var lang))
{
if (lang == "es" || lang == "pt")
return lang;
}
// 2. Verificar o cabeçalho Accept-Language
var acceptLanguage = Request.Headers["Accept-Language"].ToString();
if (!string.IsNullOrEmpty(acceptLanguage))
{
var languages = acceptLanguage.Split(',').Select(x => x.Trim().Split(';')[0]);
foreach (var language in languages)
{
if (language.StartsWith("es", StringComparison.OrdinalIgnoreCase))
return "es";
if (language.StartsWith("pt", StringComparison.OrdinalIgnoreCase))
return "pt";
}
}
// 3. TODO: Implementar detecção por GeoIP se necessário
// 4. Padrão: português
return "pt";
}
}
}

View File

@ -0,0 +1,97 @@
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Route("page")]
public class LivePageController : Controller
{
private readonly ILivePageService _livePageService;
private readonly ILogger<LivePageController> _logger;
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
{
_livePageService = livePageService;
_logger = logger;
}
[Route("{category}/{slug}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
public async Task<IActionResult> Display(string category, string slug)
{
// Se tem parâmetro preview, redirecionar para sistema de preview
if (HttpContext.Request.Query.ContainsKey("preview"))
{
_logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug);
return RedirectToAction("Display", "UserPage", new {
category = category,
slug = slug,
preview = HttpContext.Request.Query["preview"].ToString()
});
}
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
if (livePage == null)
{
_logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug);
// Fallback: tentar no sistema antigo
return RedirectToAction("Display", "UserPage", new { category = category, slug = slug });
}
// Incrementar view de forma assíncrona (não bloquear response)
_ = IncrementViewSafelyAsync(livePage.Id);
// Configurar ViewBag para indicar que é uma live page
ViewBag.IsLivePage = true;
ViewBag.PageUrl = $"https://bcards.site/page/{category}/{slug}";
ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards";
_logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug);
// Usar a mesma view do UserPage mas com dados da LivePage
return View("~/Views/UserPage/Display.cshtml", livePage);
}
[Route("{category}/{slug}/link/{linkIndex}")]
public async Task<IActionResult> TrackLinkClick(string category, string slug, int linkIndex)
{
var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug);
if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count)
{
return NotFound();
}
var link = livePage.Links[linkIndex];
// Track click de forma assíncrona
_ = IncrementLinkClickSafelyAsync(livePage.Id, linkIndex);
_logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url);
return Redirect(link.Url);
}
private async Task IncrementViewSafelyAsync(string livePageId)
{
try
{
await _livePageService.IncrementViewAsync(livePageId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId);
}
}
private async Task IncrementLinkClickSafelyAsync(string livePageId, int linkIndex)
{
try
{
await _livePageService.IncrementLinkClickAsync(livePageId, linkIndex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex);
}
}
}

View File

@ -30,10 +30,10 @@ public class ModerationController : Controller
}
[HttpGet("Dashboard")]
public async Task<IActionResult> Dashboard(int page = 1, int size = 20)
public async Task<IActionResult> Dashboard(int page = 1, int size = 20, string? filter = null)
{
var skip = (page - 1) * size;
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size);
var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size, filter);
var stats = await _moderationService.GetModerationStatsAsync();
var viewModel = new ModerationDashboardViewModel
@ -47,6 +47,7 @@ public class ModerationController : Controller
CreatedAt = p.CreatedAt,
ModerationAttempts = p.ModerationAttempts,
PlanType = p.PlanLimitations.PlanType.ToString(),
IsSpecialModeration = p.PlanLimitations.SpecialModeration ?? false,
PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken)
? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}"
: null
@ -54,7 +55,8 @@ public class ModerationController : Controller
Stats = stats,
CurrentPage = page,
PageSize = size,
HasNextPage = pendingPages.Count == size
HasNextPage = pendingPages.Count == size,
CurrentFilter = filter ?? "all"
};
return View(viewModel);
@ -114,7 +116,7 @@ public class ModerationController : Controller
user.Email,
user.Name,
page.DisplayName,
"approved");
PageStatus.Active.ToString());
}
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";

View File

@ -1,6 +1,12 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.Services;
using BCards.Web.ViewModels;
using BCards.Web.Utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.Globalization;
namespace BCards.Web.Controllers;
@ -9,11 +15,17 @@ public class PaymentController : Controller
{
private readonly IPaymentService _paymentService;
private readonly IAuthService _authService;
private readonly IUserRepository _userService;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IConfiguration _configuration;
public PaymentController(IPaymentService paymentService, IAuthService authService)
public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository, IConfiguration configuration)
{
_paymentService = paymentService;
_authService = authService;
_userService = userService;
_subscriptionRepository = subscriptionRepository;
_configuration = configuration;
}
[HttpPost]
@ -26,6 +38,8 @@ public class PaymentController : Controller
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
TempData[$"PlanType|{user.Id}"] = planType;
try
{
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
@ -43,10 +57,21 @@ public class PaymentController : Controller
}
}
public IActionResult Success()
public async Task<IActionResult> Success()
{
TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano.";
return RedirectToAction("Dashboard", "Admin");
try
{
var user = await _authService.GetCurrentUserAsync(User);
var planType = TempData[$"PlanType|{user.Id}"].ToString();
TempData["Success"] = $"Assinatura {planType} ativada com sucesso!";
return RedirectToAction("Dashboard", "Admin");
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}";
return RedirectToAction("Dashboard", "Admin");
}
}
public IActionResult Cancel()
@ -87,7 +112,45 @@ public class PaymentController : Controller
if (user == null)
return RedirectToAction("Login", "Auth");
return View(user);
try
{
// Parse do plano atual (mesmo que o Dashboard)
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var currentPlanString = userPlanType.ToString().ToLower();
var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
var viewModel = new ManageSubscriptionViewModel
{
User = user,
StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id),
PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id),
AvailablePlans = GetAvailablePlans(currentPlanString),
CurrentPeriodEnd = (DateTime?) subscription.CurrentPeriodEnd
};
// Pegar assinatura local se existir
if (!string.IsNullOrEmpty(user.StripeCustomerId))
{
// Aqui você poderia buscar a subscription local se necessário
// viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
}
return View(viewModel);
}
catch (Exception ex)
{
// Parse do plano atual também no catch
var userPlanType = Enum.TryParse<PlanType>(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
var currentPlanString = userPlanType.ToString().ToLower();
var errorViewModel = new ManageSubscriptionViewModel
{
User = user,
ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}",
AvailablePlans = GetAvailablePlans(currentPlanString)
};
return View(errorViewModel);
}
}
[HttpPost]
@ -105,4 +168,112 @@ public class PaymentController : Controller
return RedirectToAction("ManageSubscription");
}
[HttpPost]
public async Task<IActionResult> ChangePlan(string newPlanType)
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null)
return RedirectToAction("Login", "Auth");
try
{
// Para mudanças de plano, vamos usar o Stripe Checkout
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
user.Id,
newPlanType,
returnUrl!,
cancelUrl!);
return Redirect(checkoutUrl);
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao alterar plano: {ex.Message}";
return RedirectToAction("ManageSubscription");
}
}
[HttpPost]
public async Task<IActionResult> OpenStripePortal()
{
var user = await _authService.GetCurrentUserAsync(User);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
{
TempData["Error"] = "Erro: dados de assinatura não encontrados.";
return RedirectToAction("ManageSubscription");
}
try
{
var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme);
var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!);
return Redirect(portalUrl);
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}";
return RedirectToAction("ManageSubscription");
}
}
private List<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
{
var plansConfig = _configuration.GetSection("Plans");
var plans = new List<AvailablePlanViewModel>();
// Adicionar planos mensais apenas (excluir Trial e planos anuais)
var monthlyPlans = new[] { "Basic", "Professional", "Premium", "PremiumAffiliate" };
foreach (var planKey in monthlyPlans)
{
var planSection = plansConfig.GetSection(planKey);
if (planSection.Exists())
{
plans.Add(new AvailablePlanViewModel
{
PlanType = planKey.ToLower(),
DisplayName = planSection["Name"] ?? planKey,
Price = decimal.Parse(planSection["Price"] ?? "0", new CultureInfo("en-US")),
PriceId = planSection["PriceId"] ?? "",
MaxLinks = int.Parse(planSection["MaxLinks"] ?? "0"),
AllowAnalytics = bool.Parse(planSection["AllowAnalytics"] ?? "false"),
AllowCustomDomain = true, // URL personalizada em todos os planos pagos
AllowCustomThemes = bool.Parse(planSection["AllowPremiumThemes"] ?? "false"),
AllowProductLinks = bool.Parse(planSection["AllowProductLinks"] ?? "false"),
Features = planSection.GetSection("Features").Get<List<string>>() ?? new List<string>(),
IsCurrentPlan = currentPlan.Equals(planKey, StringComparison.OrdinalIgnoreCase)
});
}
}
// Marcar upgrades e filtrar downgrades
var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan);
// Se usuário está no Trial (não encontrou plano atual), todos são upgrades
if (currentPlanIndex == -1 && (currentPlan == "trial" || currentPlan == "free"))
{
foreach (var plan in plans)
{
plan.IsUpgrade = true;
}
return plans; // Mostrar todos os planos pagos como upgrade
}
// Para planos pagos, marcar apenas upgrades superiores
for (int i = 0; i < plans.Count; i++)
{
if (i > currentPlanIndex)
plans[i].IsUpgrade = true;
else if (i < currentPlanIndex)
plans[i].IsDowngrade = true;
}
// Retornar apenas plano atual e upgrades (Stripe não gerencia downgrades automaticamente)
return plans.Where(p => p.IsCurrentPlan || p.IsUpgrade).ToList();
}
}

View File

@ -8,11 +8,16 @@ namespace BCards.Web.Controllers;
public class SitemapController : Controller
{
private readonly IUserPageService _userPageService;
private readonly ILivePageService _livePageService;
private readonly ILogger<SitemapController> _logger;
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger)
public SitemapController(
IUserPageService userPageService,
ILivePageService livePageService,
ILogger<SitemapController> logger)
{
_userPageService = userPageService;
_livePageService = livePageService;
_logger = logger;
}
@ -22,42 +27,47 @@ public class SitemapController : Controller
{
try
{
var activePages = await _userPageService.GetActivePagesAsync();
// 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
var livePages = await _livePageService.GetAllActiveAsync();
// Define namespace corretamente para evitar conflitos
XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
// Construir URLs das páginas dinâmicas separadamente para evitar problemas
var dynamicUrls = livePages.Select(page =>
new XElement(ns + "url",
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category?.Replace(" ", "-")?.ToLower()}/{page.Slug}"),
new XElement(ns + "lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
new XElement(ns + "changefreq", "weekly"),
new XElement(ns + "priority", "0.8")
)
).ToList();
var sitemap = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"),
new XElement("urlset",
new XAttribute("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9"),
new XElement(ns + "urlset",
// Add static pages
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/"),
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement("changefreq", "daily"),
new XElement("priority", "1.0")
new XElement(ns + "url",
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/"),
new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement(ns + "changefreq", "daily"),
new XElement(ns + "priority", "1.0")
),
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
new XElement("lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"),
new XElement("priority", "0.9")
new XElement(ns + "url",
new XElement(ns + "loc", $"{Request.Scheme}://{Request.Host}/Home/Pricing"),
new XElement(ns + "lastmod", DateTime.UtcNow.ToString("yyyy-MM-dd")),
new XElement(ns + "changefreq", "weekly"),
new XElement(ns + "priority", "0.9")
),
// Add user pages (only active ones)
activePages.Select(page =>
new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")),
new XElement("changefreq", "weekly"),
new XElement("priority", "0.8")
)
)
// Add live pages (SEO-optimized URLs only)
dynamicUrls
)
);
_logger.LogInformation($"Generated sitemap with {activePages.Count} user pages");
_logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
return Content(sitemap.ToString(SaveOptions.DisableFormatting), "application/xml", Encoding.UTF8);
}
catch (Exception ex)
{

View File

@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Mvc;
using Stripe;
using BCards.Web.Services;
using BCards.Web.Repositories;
using BCards.Web.Configuration;
using BCards.Web.Repositories;
using BCards.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using Stripe;
using System.Diagnostics;
namespace BCards.Web.Controllers;
@ -14,17 +16,23 @@ public class StripeWebhookController : ControllerBase
private readonly ILogger<StripeWebhookController> _logger;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IUserPageService _userPageService;
private readonly IUserRepository _userRepository;
private readonly IPlanConfigurationService _planConfigurationService;
private readonly string _webhookSecret;
public StripeWebhookController(
ILogger<StripeWebhookController> logger,
ISubscriptionRepository subscriptionRepository,
IUserPageService userPageService,
IUserRepository userRepository,
IPlanConfigurationService planConfigurationService,
IOptions<StripeSettings> stripeSettings)
{
_logger = logger;
_subscriptionRepository = subscriptionRepository;
_userPageService = userPageService;
_userRepository = userRepository;
_planConfigurationService = planConfigurationService;
_webhookSecret = stripeSettings.Value.WebhookSecret ?? "";
}
@ -34,12 +42,14 @@ public class StripeWebhookController : ControllerBase
try
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
_logger.LogInformation($"Recebido:{json}");
if (string.IsNullOrEmpty(_webhookSecret))
{
_logger.LogWarning("Webhook secret not configured");
return BadRequest("Webhook secret not configured");
}
_logger.LogWarning($"Recebido:{json}");
var stripeSignature = Request.Headers["Stripe-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(stripeSignature))
@ -48,6 +58,7 @@ public class StripeWebhookController : ControllerBase
return BadRequest("Missing Stripe signature");
}
_logger.LogInformation($"Contruir evento Stripe: {json}");
var stripeEvent = EventUtility.ConstructEvent(
json,
stripeSignature,
@ -55,26 +66,30 @@ public class StripeWebhookController : ControllerBase
throwOnApiVersionMismatch: false
);
_logger.LogInformation($"Processing Stripe webhook: {stripeEvent.Type}");
_logger.LogInformation($"[DEBUG] Processing Stripe webhook: {stripeEvent.Type}");
switch (stripeEvent.Type)
{
case Events.InvoicePaymentSucceeded:
case "invoice.payment_succeeded":
await HandlePaymentSucceeded(stripeEvent);
break;
case Events.InvoicePaymentFailed:
case "invoice.payment_failed":
await HandlePaymentFailed(stripeEvent);
break;
case Events.CustomerSubscriptionDeleted:
case "customer.subscription.deleted":
await HandleSubscriptionDeleted(stripeEvent);
break;
case Events.CustomerSubscriptionUpdated:
case "customer.subscription.updated":
await HandleSubscriptionUpdated(stripeEvent);
break;
case "customer.subscription.created":
await HandleSubscriptionCreated(stripeEvent);
break;
default:
_logger.LogInformation($"Unhandled webhook event type: {stripeEvent.Type}");
break;
@ -95,125 +110,358 @@ public class StripeWebhookController : ControllerBase
}
private async Task HandlePaymentSucceeded(Event stripeEvent)
{
var traceId = Guid.NewGuid().ToString();
try
{
_logger.LogInformation($"[TID: {traceId}] - Staring HandlePaymentSucceeded");
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"[TID: {traceId}] - Payment succeeded for customer: {invoice.CustomerId}");
var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null)
{
subscription.Status = "active";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Reactivate user pages
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.PendingPayment))
{
page.Status = ViewModels.PageStatus.Active;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"[TID: {traceId}] - Reactivated {userPages.Count} pages for user {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"[TID: {traceId}] - Unexpected event type on HandlePaymentSucceeded: {stripeEvent.Type}");
}
await Task.Delay(4000);
}
catch (Exception ex)
{
_logger.LogError(ex, $"[TID: {traceId}] - Error handling payment succeeded event");
await Task.Delay(4000);
throw new Exception($"[TID: {traceId}] - Error handling payment succeeded event", ex);
}
}
private async Task HandlePaymentFailed(Event stripeEvent)
{
try
{
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
var subscriptionId = GetSubscriptionId(stripeEvent);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null)
{
subscription.Status = "past_due";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Set pages to pending payment
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
{
page.Status = ViewModels.PageStatus.PendingPayment;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling payment failed event");
throw new Exception("Error handling payment failed event", ex);
}
}
private async Task HandleSubscriptionDeleted(Event stripeEvent)
{
try
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
subscription.Status = "cancelled";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Downgrade to trial or deactivate pages
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
{
page.Status = ViewModels.PageStatus.Expired;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling subscription deleted failed event");
throw new Exception("Error handling subscription deleted failed event", ex);
}
}
private async Task HandleSubscriptionUpdated(Event stripeEvent)
{
try
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"Subscription updated: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
subscription.UpdatedAt = DateTime.UtcNow;
// Update plan type based on Stripe price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
if (!string.IsNullOrEmpty(priceId))
{
subscription.PlanType = _planConfigurationService.GetPlanNameFromPriceId(priceId);
}
await _subscriptionRepository.UpdateAsync(subscription);
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
}
}
else
{
_logger.LogWarning($"Unexpected event type on HandlePaymentFailed: {stripeEvent.Type}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling subscription updated failed event");
throw new Exception("Error handling subscription updated failed event", ex);
}
}
private async Task HandleSubscriptionCreated(Event stripeEvent)
{
var traceId = Guid.NewGuid().ToString();
try
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - HandleSubscriptionCreated started");
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Subscription created: {stripeSubscription.Id} for customer: {stripeSubscription.CustomerId}");
// Get subscription record from our database
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Looking for existing subscription with ID: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Found existing subscription: {subscription.Id}");
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
// Update subscription status to active
subscription.Status = "active";
subscription.UpdatedAt = DateTime.UtcNow;
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
// Update plan type based on Stripe price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
if (!string.IsNullOrEmpty(priceId))
{
subscription.PlanType = _planConfigurationService.GetPlanNameFromPriceId(priceId);
}
await _subscriptionRepository.UpdateAsync(subscription);
// Activate user pages that were pending payment or trial
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p =>
p.Status == ViewModels.PageStatus.PendingPayment ||
p.Status == ViewModels.PageStatus.Expired))
{
page.Status = ViewModels.PageStatus.Active;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"[TID: {traceId}] - Activated subscription and {userPages.Count()} pages for user {subscription.UserId}");
}
else
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Subscription not found in database: {stripeSubscription.Id}");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Calling HandleSubscriptionCreatedForNewSubscription");
// Try to find user by Stripe Customer ID and create/update subscription
await HandleSubscriptionCreatedForNewSubscription(stripeSubscription);
}
}
else
{
_logger.LogWarning($"[TID: {traceId}] - Unexpected event type on HandleSubscriptionCreated: {stripeEvent.Type}");
}
await Task.Delay(4000);
}
catch (Exception ex)
{
_logger.LogError(ex, "[TID: {traceId}] - Error handling subscription created event");
await Task.Delay(4000);
throw new Exception("[TID: {traceId}] - Error handling subscription created event", ex);
}
}
private string GetSubscriptionId(Event stripeEvent)
{
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"Payment succeeded for customer: {invoice.CustomerId}");
var subscriptionLineItem = invoice.Lines?.Data
.FirstOrDefault(line =>
!string.IsNullOrEmpty(line.SubscriptionId) ||
line.Subscription != null
);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
if (subscription != null)
string subscriptionId = null;
if (subscriptionLineItem != null)
{
subscription.Status = "active";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Tenta obter o ID da assinatura de duas formas diferentes
subscriptionId = subscriptionLineItem.SubscriptionId
?? subscriptionLineItem.Subscription?.Id;
}
// Reactivate user pages
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.PendingPayment))
return subscriptionId;
}
else if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
return stripeSubscription.Id;
}
return null;
}
private async Task HandleSubscriptionCreatedForNewSubscription(Subscription stripeSubscription)
{
var traceId = Guid.NewGuid().ToString();
try
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - HandleSubscriptionCreatedForNewSubscription started for customer: {stripeSubscription.CustomerId}");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Getting MongoDB database");
var mongoDatabase = HttpContext.RequestServices.GetRequiredService<IMongoDatabase>();
var usersCollection = mongoDatabase.GetCollection<Models.User>("users");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Searching for user with StripeCustomerId: {stripeSubscription.CustomerId}");
var user = await usersCollection.Find(u => u.StripeCustomerId == stripeSubscription.CustomerId).FirstOrDefaultAsync();
if (user != null)
{
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Found user: {user.Id} ({user.Email})");
_logger.LogInformation($"[TID: {traceId}] - Found user {user.Id} for customer {stripeSubscription.CustomerId}");
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
// Get plan type from price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
var planType = !string.IsNullOrEmpty(priceId) ? _planConfigurationService.GetPlanNameFromPriceId(priceId) : "Trial";
_logger.LogInformation($"[TID: {traceId}] - PriceId: {priceId}, PlanType: {planType}, User: {user.Id}");
// Create new subscription in our database
var newSubscription = new Models.Subscription
{
Id = MongoDB.Bson.ObjectId.GenerateNewId().ToString(),
UserId = user.Id,
StripeSubscriptionId = stripeSubscription.Id,
Status = "active",
PlanType = planType,
CurrentPeriodStart = subItem.CurrentPeriodStart,
CurrentPeriodEnd = subItem.CurrentPeriodEnd,
CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
_logger.LogInformation($"[TID: {traceId}] - try to save into database (subscription) - User: {user.Id}");
await _subscriptionRepository.CreateAsync(newSubscription);
_logger.LogInformation($"[TID: {traceId}] - Created new subscription {newSubscription.Id} for user {user.Id}");
_logger.LogInformation($"[TID: {traceId}] - Updating user {user.Id} CurrentPlan from '{user.CurrentPlan}' to '{planType}'");
user.CurrentPlan = planType;
user.UpdatedAt = DateTime.UtcNow;
var usersCollection2 = mongoDatabase.GetCollection<Models.User>("users");
await usersCollection2.ReplaceOneAsync(u => u.Id == user.Id, user);
_logger.LogInformation($"[TID: {traceId}] - User {user.Id} CurrentPlan updated to '{planType}'");
var userPages = await _userPageService.GetUserPagesAsync(user.Id);
foreach (var page in userPages.Where(p =>
p.Status == ViewModels.PageStatus.PendingPayment ||
p.Status == ViewModels.PageStatus.Expired))
{
page.Status = ViewModels.PageStatus.Active;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Reactivated {userPages.Count} pages for user {subscription.UserId}");
_logger.LogInformation($"[TID: {traceId}] - Activated {userPages.Count()} pages for user {user.Id} after subscription creation");
}
}
}
private async Task HandlePaymentFailed(Event stripeEvent)
{
if (stripeEvent.Data.Object is Invoice invoice)
{
_logger.LogInformation($"Payment failed for customer: {invoice.CustomerId}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(invoice.SubscriptionId);
if (subscription != null)
else
{
subscription.Status = "past_due";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
_logger.LogError($"[DEBUG] [TID: {traceId}] - User not found for Stripe customer ID: {stripeSubscription.CustomerId}");
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - Will try to list some users to debug");
// Set pages to pending payment
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
var allUsers = await usersCollection.Find(_ => true).Limit(5).ToListAsync();
foreach (var u in allUsers)
{
page.Status = ViewModels.PageStatus.PendingPayment;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
_logger.LogInformation($"[DEBUG] [TID: {traceId}] - User in DB: {u.Id} - {u.Email} - StripeCustomerId: '{u.StripeCustomerId}'");
}
_logger.LogInformation($"Set {userPages.Count} pages to pending payment for user {subscription.UserId}");
}
}
}
private async Task HandleSubscriptionDeleted(Event stripeEvent)
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
catch (Exception ex)
{
_logger.LogInformation($"Subscription cancelled: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
subscription.Status = "cancelled";
subscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(subscription);
// Downgrade to trial or deactivate pages
var userPages = await _userPageService.GetUserPagesAsync(subscription.UserId);
foreach (var page in userPages.Where(p => p.Status == ViewModels.PageStatus.Active))
{
page.Status = ViewModels.PageStatus.Expired;
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
_logger.LogInformation($"Deactivated {userPages.Count} pages for cancelled subscription {subscription.UserId}");
}
_logger.LogError(ex, $"[TID: {traceId}] - Error handling new subscription creation for {stripeSubscription.Id}");
throw;
}
}
private async Task HandleSubscriptionUpdated(Event stripeEvent)
{
if (stripeEvent.Data.Object is Subscription stripeSubscription)
{
_logger.LogInformation($"Subscription updated: {stripeSubscription.Id}");
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = stripeSubscription.CurrentPeriodStart;
subscription.CurrentPeriodEnd = stripeSubscription.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
subscription.UpdatedAt = DateTime.UtcNow;
// Update plan type based on Stripe price ID
var priceId = stripeSubscription.Items.Data.FirstOrDefault()?.Price.Id;
if (!string.IsNullOrEmpty(priceId))
{
subscription.PlanType = MapPriceIdToPlanType(priceId);
}
await _subscriptionRepository.UpdateAsync(subscription);
_logger.LogInformation($"Updated subscription for user {subscription.UserId}");
}
}
}
private string MapPriceIdToPlanType(string priceId)
{
// Map Stripe price IDs to plan types
// This would be configured based on your actual Stripe price IDs
return priceId switch
{
var id when id.Contains("basic") => "basic",
var id when id.Contains("professional") => "professional",
var id when id.Contains("premium") => "premium",
_ => "trial"
};
}
}

View File

@ -0,0 +1,155 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using BCards.Web.Services;
using BCards.Web.ViewModels;
using System.Security.Claims;
namespace BCards.Web.Controllers;
[Authorize]
[Route("subscription")]
public class SubscriptionController : Controller
{
private readonly IPaymentService _paymentService;
private readonly ILogger<SubscriptionController> _logger;
public SubscriptionController(
IPaymentService paymentService,
ILogger<SubscriptionController> logger)
{
_paymentService = paymentService;
_logger = logger;
}
[HttpGet("cancel")]
public async Task<IActionResult> Cancel()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
return RedirectToAction("Login", "Auth");
var subscription = await _paymentService.GetSubscriptionDetailsAsync(userId);
if (subscription == null)
{
TempData["Error"] = "Nenhuma assinatura ativa encontrada.";
return RedirectToAction("ManageSubscription", "Payment");
}
// Calcular opções de reembolso
var (canRefundFull, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(subscription.Id);
// Obter datas via SubscriptionItem
var subscriptionItemService = new Stripe.SubscriptionItemService();
var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id);
var viewModel = new CancelSubscriptionViewModel
{
SubscriptionId = subscription.Id,
PlanName = subscription.Items.Data.FirstOrDefault()?.Price.Nickname ?? "Plano Atual",
CurrentPeriodEnd = subItem.CurrentPeriodEnd,
CanRefundFull = canRefundFull,
CanRefundPartial = canRefundPartial,
RefundAmount = refundAmount,
DaysRemaining = (subItem.CurrentPeriodEnd - DateTime.UtcNow).Days
};
return View(viewModel);
}
[HttpPost("cancel")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ProcessCancel(CancelSubscriptionRequest request)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
return RedirectToAction("Login", "Auth");
try
{
bool success = false;
string message = "";
switch (request.CancelType)
{
case "immediate_with_refund":
success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true);
message = success ? "Assinatura cancelada com reembolso total. O valor será retornado em até 10 dias úteis." : "Erro ao processar cancelamento.";
break;
case "immediate_no_refund":
success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false);
message = success ? "Assinatura cancelada imediatamente." : "Erro ao cancelar assinatura.";
break;
case "partial_refund":
success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false);
var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId);
if (success && canRefundPartial && refundAmount > 0)
{
message = $"Assinatura cancelada com reembolso parcial de R$ {refundAmount:F2}. O valor será retornado em até 10 dias úteis.";
}
else
{
message = success ? "Assinatura cancelada. Reembolso parcial não disponível." : "Erro ao cancelar assinatura.";
}
break;
case "at_period_end":
default:
success = await _paymentService.CancelSubscriptionAtPeriodEndAsync(request.SubscriptionId);
message = success ? "Assinatura será cancelada no final do período atual." : "Erro ao agendar cancelamento.";
break;
}
if (success)
{
TempData["Success"] = message;
_logger.LogInformation($"User {userId} cancelled subscription {request.SubscriptionId} with type {request.CancelType}");
}
else
{
TempData["Error"] = message;
_logger.LogError($"Failed to cancel subscription {request.SubscriptionId} for user {userId}");
}
}
catch (Exception ex)
{
TempData["Error"] = "Ocorreu um erro ao processar o cancelamento. Tente novamente.";
_logger.LogError(ex, $"Error cancelling subscription {request.SubscriptionId} for user {userId}");
}
return RedirectToAction("ManageSubscription", "Payment");
}
[HttpPost("reactivate")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Reactivate(string subscriptionId)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
return RedirectToAction("Login", "Auth");
try
{
// Reativar assinatura removendo o agendamento de cancelamento
var success = await _paymentService.ReactivateSubscriptionAsync(subscriptionId);
if (success)
{
TempData["Success"] = "Assinatura reativada com sucesso!";
_logger.LogInformation($"User {userId} reactivated subscription {subscriptionId}");
}
else
{
TempData["Error"] = "Erro ao reativar assinatura.";
}
}
catch (Exception ex)
{
TempData["Error"] = "Ocorreu um erro ao reativar a assinatura.";
_logger.LogError(ex, $"Error reactivating subscription {subscriptionId} for user {userId}");
}
return RedirectToAction("ManageSubscription", "Payment");
}
}

View File

@ -0,0 +1,172 @@
#if TESTING
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
[Authorize]
[ApiController]
[Route("testing/tools")]
public class TestToolsController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IUserPageService _userPageService;
private readonly ILivePageService _livePageService;
private readonly IPlanConfigurationService _planConfigurationService;
private readonly IWebHostEnvironment _environment;
public TestToolsController(
IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository,
IUserPageService userPageService,
ILivePageService livePageService,
IPlanConfigurationService planConfigurationService,
IWebHostEnvironment environment)
{
_userRepository = userRepository;
_subscriptionRepository = subscriptionRepository;
_userPageService = userPageService;
_livePageService = livePageService;
_planConfigurationService = planConfigurationService;
_environment = environment;
}
[HttpPost("plan")]
public async Task<IActionResult> SetPlan([FromBody] SetPlanRequest request)
{
if (!_environment.IsEnvironment("Testing"))
{
return NotFound();
}
if (!Enum.TryParse<PlanType>(request.Plan, true, out var planType))
{
return BadRequest(new { error = $"Plano desconhecido: {request.Plan}" });
}
var email = string.IsNullOrWhiteSpace(request.Email)
? TestUserDefaults.Email
: request.Email!.Trim();
var user = await _userRepository.GetByEmailAsync(email);
if (user == null)
{
return NotFound(new { error = $"Usuário de teste não encontrado para o e-mail {email}" });
}
var planLimits = _planConfigurationService.GetPlanLimitations(planType);
var normalizedPlan = planLimits.PlanType ?? planType.ToString().ToLowerInvariant();
user.CurrentPlan = normalizedPlan;
user.SubscriptionStatus = "active";
user.UpdatedAt = DateTime.UtcNow;
await _userRepository.UpdateAsync(user);
var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
if (subscription == null)
{
subscription = new Subscription
{
UserId = user.Id,
StripeSubscriptionId = $"test-{Guid.NewGuid():N}",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
}
subscription.PlanType = normalizedPlan;
subscription.Status = "active";
subscription.MaxLinks = planLimits.MaxLinks;
subscription.AllowAnalytics = planLimits.AllowAnalytics;
subscription.AllowCustomThemes = planLimits.AllowCustomThemes;
subscription.AllowCustomDomain = planLimits.AllowCustomDomain;
subscription.AllowMultipleDomains = planLimits.AllowMultipleDomains;
subscription.PrioritySupport = planLimits.PrioritySupport;
subscription.CurrentPeriodStart = DateTime.UtcNow.Date;
subscription.CurrentPeriodEnd = DateTime.UtcNow.Date.AddMonths(1);
subscription.CancelAtPeriodEnd = false;
subscription.UpdatedAt = DateTime.UtcNow;
if (string.IsNullOrEmpty(subscription.Id))
{
await _subscriptionRepository.CreateAsync(subscription);
}
else
{
await _subscriptionRepository.UpdateAsync(subscription);
}
var pages = await _userPageService.GetUserPagesAsync(user.Id);
var originalPagesCount = pages.Count;
var removed = 0;
if (request.ResetPages)
{
foreach (var page in pages)
{
await _livePageService.DeleteByOriginalPageIdAsync(page.Id);
await _userPageService.DeletePageAsync(page.Id);
removed++;
}
pages = new List<UserPage>();
}
else
{
foreach (var page in pages)
{
page.PlanLimitations = ClonePlanLimitations(planLimits);
page.UpdatedAt = DateTime.UtcNow;
await _userPageService.UpdatePageAsync(page);
}
}
return Ok(new
{
email,
plan = normalizedPlan,
pagesAffected = request.ResetPages ? originalPagesCount : pages.Count,
pagesRemoved = removed
});
}
private static PlanLimitations ClonePlanLimitations(PlanLimitations source)
{
return new PlanLimitations
{
MaxLinks = source.MaxLinks,
AllowCustomThemes = source.AllowCustomThemes,
AllowAnalytics = source.AllowAnalytics,
AllowCustomDomain = source.AllowCustomDomain,
AllowMultipleDomains = source.AllowMultipleDomains,
PrioritySupport = source.PrioritySupport,
PlanType = source.PlanType,
MaxProductLinks = source.MaxProductLinks,
MaxOGExtractionsPerDay = source.MaxOGExtractionsPerDay,
AllowProductLinks = source.AllowProductLinks,
SpecialModeration = source.SpecialModeration,
OGExtractionsUsedToday = 0,
LastExtractionDate = null,
AllowDocumentUpload = source.AllowDocumentUpload,
MaxDocuments = source.MaxDocuments
};
}
private static class TestUserDefaults
{
public const string Email = "test.user@example.com";
}
public class SetPlanRequest
{
public string? Email { get; set; }
public string Plan { get; set; } = "trial";
public bool ResetPages { get; set; }
}
}
#endif

View File

@ -1,9 +1,10 @@
using BCards.Web.Models;
using BCards.Web.Services;
using BCards.Web.Utils;
using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers;
//[Route("[controller]")]
public class UserPageController : Controller
{
private readonly IUserPageService _userPageService;
@ -11,24 +12,25 @@ public class UserPageController : Controller
private readonly ISeoService _seoService;
private readonly IThemeService _themeService;
private readonly IModerationService _moderationService;
private readonly ILogger<UserPageController> _logger;
public UserPageController(
IUserPageService userPageService,
ICategoryService categoryService,
ISeoService seoService,
IThemeService themeService,
IModerationService moderationService)
IModerationService moderationService,
ILogger<UserPageController> logger)
{
_userPageService = userPageService;
_categoryService = categoryService;
_seoService = seoService;
_themeService = themeService;
_moderationService = moderationService;
_logger = logger;
}
//[Route("{category}/{slug}")]
//VOltar a linha abaixo em prod
//[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "preview" })]
public async Task<IActionResult> Display(string category, string slug)
{
var userPage = await _userPageService.GetPageAsync(category, slug);
@ -43,6 +45,9 @@ public class UserPageController : Controller
var isPreview = HttpContext.Items.ContainsKey("IsPreview") && (bool)HttpContext.Items["IsPreview"];
var previewToken = Request.Query["preview"].FirstOrDefault();
_logger.LogDebug("Request - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}, UserId: {UserId}",
userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken), userPage.UserId);
if (!string.IsNullOrEmpty(previewToken))
{
// Handle preview request
@ -85,11 +90,18 @@ public class UserPageController : Controller
var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj);
// Record page view (async, don't wait) - only for non-preview requests
_logger.LogDebug("View Count - Page: {Slug}, Status: {Status}, IsPreview: {IsPreview}, PreviewToken: {PreviewToken}",
userPage.Slug, userPage.Status, isPreview, !string.IsNullOrEmpty(previewToken));
if (!isPreview)
{
_logger.LogDebug("Recording view for page {Slug}", userPage.Slug);
var referrer = Request.Headers["Referer"].FirstOrDefault();
var userAgent = Request.Headers["User-Agent"].FirstOrDefault();
_ = Task.Run(() => _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent));
_ = _userPageService.RecordPageViewAsync(userPage.Id, referrer, userAgent);
}
else
{
_logger.LogDebug("NOT recording view - isPreview = true for page {Slug}", userPage.Slug);
}
ViewBag.SeoSettings = seoSettings;
@ -128,4 +140,5 @@ public class UserPageController : Controller
return View("Display", userPage);
}
}

View File

@ -0,0 +1,110 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MongoDB.Driver;
using MongoDB.Bson;
using Stripe;
namespace BCards.Web.HealthChecks;
/// <summary>
/// Health check para serviços CRÍTICOS - falha = ALERTA VERMELHO 🔴
/// - MongoDB (páginas não funcionam)
/// - Stripe (pagamentos não funcionam)
/// - Website (implícito - se este check executa, o site funciona)
/// </summary>
public class CriticalServicesHealthCheck : IHealthCheck
{
private readonly IMongoDatabase _database;
private readonly HttpClient _httpClient;
private readonly ILogger<CriticalServicesHealthCheck> _logger;
public CriticalServicesHealthCheck(
IMongoDatabase database,
HttpClient httpClient,
ILogger<CriticalServicesHealthCheck> logger)
{
_database = database;
_httpClient = httpClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var results = new Dictionary<string, object>();
var allCritical = true;
var criticalFailures = new List<string>();
// 1. MongoDB Check
try
{
await _database.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1), cancellationToken: cancellationToken);
results["mongodb"] = new { status = "healthy", service = "database" };
_logger.LogDebug("✅ MongoDB está respondendo");
}
catch (Exception ex)
{
allCritical = false;
criticalFailures.Add("MongoDB");
results["mongodb"] = new { status = "unhealthy", error = ex.Message, service = "database" };
_logger.LogError(ex, "🔴 CRÍTICO: MongoDB falhou - páginas de usuários não funcionam!");
// Pequeno delay para garantir que logs críticos sejam enviados
await Task.Delay(1000, cancellationToken);
}
// 2. Stripe API Check
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var response = await _httpClient.GetAsync("https://api.stripe.com/healthcheck", cts.Token);
if (response.IsSuccessStatusCode)
{
results["stripe"] = new { status = "healthy", service = "payment" };
_logger.LogDebug("✅ Stripe API está respondendo");
}
else
{
allCritical = false;
criticalFailures.Add("Stripe");
results["stripe"] = new { status = "unhealthy", status_code = (int)response.StatusCode, service = "payment" };
_logger.LogError("🔴 CRÍTICO: Stripe API falhou - pagamentos não funcionam! Status: {StatusCode}",
(int)response.StatusCode);
await Task.Delay(1000, cancellationToken);
}
}
catch (Exception ex)
{
allCritical = false;
criticalFailures.Add("Stripe");
results["stripe"] = new { status = "unhealthy", error = ex.Message, service = "payment" };
_logger.LogError(ex, "🔴 CRÍTICO: Stripe API inacessível - pagamentos não funcionam!");
await Task.Delay(1000, cancellationToken);
}
// 3. Self Health Check removido - se este código está executando, o website já está funcionando
results["website"] = new { status = "healthy", service = "website" };
_logger.LogDebug("✅ Website está operacional (health check executando)");
var data = new Dictionary<string, object>
{
{ "services", results },
{ "critical_failures", criticalFailures },
{ "failure_count", criticalFailures.Count },
{ "total_critical_services", 3 }
};
if (!allCritical)
{
var failureList = string.Join(", ", criticalFailures);
_logger.LogError("🔴 ALERTA VERMELHO: Serviços críticos falharam: {Services}", failureList);
return HealthCheckResult.Unhealthy($"Serviços críticos falharam: {failureList}", data: data);
}
return HealthCheckResult.Healthy("Todos os serviços críticos funcionando", data: data);
}
}

View File

@ -0,0 +1,134 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class ExternalServicesHealthCheck : IHealthCheck
{
private readonly HttpClient _httpClient;
private readonly ILogger<ExternalServicesHealthCheck> _logger;
public ExternalServicesHealthCheck(HttpClient httpClient, ILogger<ExternalServicesHealthCheck> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var results = new Dictionary<string, object>();
var allHealthy = true;
var hasUnhealthy = false;
try
{
// Lista de serviços externos para verificar
var services = new Dictionary<string, string>
{
{ "google_oauth", "https://accounts.google.com" },
{ "microsoft_oauth", "https://login.microsoftonline.com" }
};
foreach (var service in services)
{
var serviceStopwatch = Stopwatch.StartNew();
try
{
using var response = await _httpClient.GetAsync(service.Value, cancellationToken);
serviceStopwatch.Stop();
var serviceResult = new Dictionary<string, object>
{
{ "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" },
{ "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" },
{ "status_code", (int)response.StatusCode },
{ "url", service.Value }
};
results[service.Key] = serviceResult;
if (!response.IsSuccessStatusCode)
{
allHealthy = false;
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable ||
response.StatusCode == System.Net.HttpStatusCode.InternalServerError)
{
hasUnhealthy = true;
}
}
_logger.LogInformation("External service {Service} health check: {Status} in {Duration}ms",
service.Key, response.StatusCode, serviceStopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
serviceStopwatch.Stop();
allHealthy = false;
hasUnhealthy = true;
results[service.Key] = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" },
{ "error", ex.Message },
{ "url", service.Value }
};
// Usar Warning para OAuth providers (alerta amarelo)
if (service.Key.Contains("oauth"))
{
_logger.LogWarning("🟡 OAuth service {Service} offline - usuários não conseguem fazer login: {Error}",
service.Key, ex.Message);
}
else
{
_logger.LogError(ex, "🔴 Critical service {Service} failed", service.Key);
}
}
}
stopwatch.Stop();
var totalDuration = stopwatch.ElapsedMilliseconds;
var data = new Dictionary<string, object>
{
{ "status", hasUnhealthy ? "unhealthy" : (allHealthy ? "healthy" : "degraded") },
{ "duration", $"{totalDuration}ms" },
{ "services", results },
{ "total_services", services.Count },
{ "healthy_services", results.Values.Count(r => ((Dictionary<string, object>)r)["status"].ToString() == "healthy") }
};
if (hasUnhealthy)
{
return HealthCheckResult.Unhealthy("One or more external services are unhealthy", data: data);
}
if (!allHealthy)
{
return HealthCheckResult.Degraded("Some external services have issues", data: data);
}
return HealthCheckResult.Healthy($"All external services are responsive ({totalDuration}ms)", data: data);
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "External services health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"External services check failed: {ex.Message}", ex, data);
}
}
}

View File

@ -0,0 +1,73 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using MongoDB.Driver;
using MongoDB.Bson;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class MongoDbHealthCheck : IHealthCheck
{
private readonly IMongoDatabase _database;
private readonly ILogger<MongoDbHealthCheck> _logger;
public MongoDbHealthCheck(IMongoDatabase database, ILogger<MongoDbHealthCheck> logger)
{
_database = database;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Executa ping no MongoDB
var command = new BsonDocument("ping", 1);
await _database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "database", _database.DatabaseNamespace.DatabaseName },
{ "connection_state", "connected" },
{ "latency", duration }
};
// Status baseado na latência
if (duration > 5000) // > 5s
return HealthCheckResult.Unhealthy($"MongoDB response time too high: {duration}ms", data: data);
if (duration > 2000) // > 2s
return HealthCheckResult.Degraded($"MongoDB response time elevated: {duration}ms", data: data);
return HealthCheckResult.Healthy($"MongoDB is responsive ({duration}ms)", data: data);
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "MongoDB health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "database", _database.DatabaseNamespace.DatabaseName },
{ "connection_state", "disconnected" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"MongoDB connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -0,0 +1,61 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using BCards.Web.Services;
namespace BCards.Web.HealthChecks;
public class OAuthProvidersHealthCheck : IHealthCheck
{
private readonly IOAuthHealthService _oauthHealthService;
private readonly ILogger<OAuthProvidersHealthCheck> _logger;
public OAuthProvidersHealthCheck(IOAuthHealthService oauthHealthService, ILogger<OAuthProvidersHealthCheck> logger)
{
_oauthHealthService = oauthHealthService;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var status = await _oauthHealthService.CheckOAuthProvidersAsync();
var data = new Dictionary<string, object>
{
{ "google_status", status.GoogleStatus },
{ "microsoft_status", status.MicrosoftStatus },
{ "all_providers_healthy", status.AllProvidersHealthy },
{ "any_provider_healthy", status.AnyProviderHealthy },
{ "checked_at", status.CheckedAt }
};
// Política de saúde: OAuth offline é DEGRADED, não UNHEALTHY
if (!status.AnyProviderHealthy)
{
_logger.LogError("🔴 CRÍTICO: Todos os OAuth providers estão offline - login totalmente indisponível!");
return HealthCheckResult.Degraded("Todos os OAuth providers estão offline", data: data);
}
if (!status.AllProvidersHealthy)
{
var offlineProviders = new List<string>();
if (!status.GoogleAvailable) offlineProviders.Add("Google");
if (!status.MicrosoftAvailable) offlineProviders.Add("Microsoft");
_logger.LogWarning("🟡 OAuth providers offline: {Providers} - alguns usuários não conseguem fazer login",
string.Join(", ", offlineProviders));
return HealthCheckResult.Degraded($"OAuth providers offline: {string.Join(", ", offlineProviders)}", data: data);
}
return HealthCheckResult.Healthy("Todos os OAuth providers estão funcionando", data: data);
}
catch (Exception ex)
{
_logger.LogError(ex, "🔴 Erro ao verificar saúde dos OAuth providers");
return HealthCheckResult.Degraded($"Erro na verificação OAuth: {ex.Message}");
}
}
}

View File

@ -0,0 +1,95 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SendGrid;
using SendGrid.Helpers.Mail;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class SendGridHealthCheck : IHealthCheck
{
private readonly ISendGridClient _sendGridClient;
private readonly ILogger<SendGridHealthCheck> _logger;
private readonly IConfiguration _configuration;
public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger<SendGridHealthCheck> logger, IConfiguration configuration)
{
_sendGridClient = sendGridClient;
_logger = logger;
_configuration = configuration;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Testa a API do SendGrid fazendo uma validação de API key
// Usando endpoint de templates que não requer parâmetros específicos
var response = await _sendGridClient.RequestAsync(
method: SendGridClient.Method.GET,
urlPath: "templates",
queryParams: "{\"generations\":\"legacy,dynamic\",\"page_size\":1}",
cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
var apiKey = _configuration["SendGrid:ApiKey"];
var apiKeyPrefix = string.IsNullOrEmpty(apiKey) ? "not_configured" :
apiKey.Substring(0, Math.Min(8, apiKey.Length)) + "...";
_logger.LogInformation("SendGrid health check completed with status {StatusCode} in {Duration}ms",
response.StatusCode, duration);
var data = new Dictionary<string, object>
{
{ "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" },
{ "duration", $"{duration}ms" },
{ "status_code", (int)response.StatusCode },
{ "api_key_prefix", apiKeyPrefix },
{ "latency", duration }
};
// Verifica se a resposta foi bem-sucedida
if (response.IsSuccessStatusCode)
{
// Status baseado na latência
if (duration > 8000) // > 8s
return HealthCheckResult.Unhealthy($"SendGrid response time too high: {duration}ms", data: data);
if (duration > 4000) // > 4s
return HealthCheckResult.Degraded($"SendGrid response time elevated: {duration}ms", data: data);
return HealthCheckResult.Healthy($"SendGrid API is responsive ({duration}ms)", data: data);
}
else
{
data["error"] = $"HTTP {response.StatusCode}";
data["response_body"] = response.Body;
return HealthCheckResult.Unhealthy(
$"SendGrid API returned {response.StatusCode}",
data: data);
}
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "SendGrid health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -0,0 +1,94 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using BCards.Web.Configuration;
using Stripe;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class StripeHealthCheck : IHealthCheck
{
private readonly StripeSettings _stripeSettings;
private readonly ILogger<StripeHealthCheck> _logger;
public StripeHealthCheck(IOptions<StripeSettings> stripeSettings, ILogger<StripeHealthCheck> logger)
{
_stripeSettings = stripeSettings.Value;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Configura Stripe temporariamente para o teste
StripeConfiguration.ApiKey = _stripeSettings.SecretKey;
// Testa conectividade listando produtos (limite 1 para ser rápido)
var productService = new ProductService();
var options = new ProductListOptions { Limit = 1 };
await productService.ListAsync(options, cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("Stripe health check completed successfully in {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "api_key_prefix", _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "..." },
{ "latency", duration }
};
// Status baseado na latência
if (duration > 10000) // > 10s
return HealthCheckResult.Unhealthy($"Stripe response time too high: {duration}ms", data: data);
if (duration > 5000) // > 5s
return HealthCheckResult.Degraded($"Stripe response time elevated: {duration}ms", data: data);
return HealthCheckResult.Healthy($"Stripe API is responsive ({duration}ms)", data: data);
}
catch (StripeException stripeEx)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(stripeEx, "Stripe health check failed after {Duration}ms: {Error}", duration, stripeEx.Message);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", stripeEx.Message },
{ "error_code", stripeEx.StripeError?.Code ?? "unknown" },
{ "error_type", stripeEx.StripeError?.Type ?? "unknown" }
};
return HealthCheckResult.Unhealthy($"Stripe API error: {stripeEx.Message}", stripeEx, data);
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "Stripe health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -0,0 +1,134 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class SystemResourcesHealthCheck : IHealthCheck
{
private readonly ILogger<SystemResourcesHealthCheck> _logger;
private static readonly DateTime _startTime = DateTime.UtcNow;
public SystemResourcesHealthCheck(ILogger<SystemResourcesHealthCheck> logger)
{
_logger = logger;
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Informações de memória
var totalMemory = GC.GetTotalMemory(false);
var workingSet = Environment.WorkingSet;
// Informações do processo atual
using var currentProcess = Process.GetCurrentProcess();
var cpuUsage = GetCpuUsage(currentProcess);
// Uptime
var uptime = DateTime.UtcNow - _startTime;
var uptimeString = FormatUptime(uptime);
// Thread count
var threadCount = currentProcess.Threads.Count;
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
var data = new Dictionary<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "memory", new Dictionary<string, object>
{
{ "total_managed_mb", Math.Round(totalMemory / 1024.0 / 1024.0, 2) },
{ "working_set_mb", Math.Round(workingSet / 1024.0 / 1024.0, 2) },
{ "gc_generation_0", GC.CollectionCount(0) },
{ "gc_generation_1", GC.CollectionCount(1) },
{ "gc_generation_2", GC.CollectionCount(2) }
}
},
{ "process", new Dictionary<string, object>
{
{ "id", currentProcess.Id },
{ "threads", threadCount },
{ "handles", currentProcess.HandleCount },
{ "uptime", uptimeString },
{ "uptime_seconds", (int)uptime.TotalSeconds }
}
},
{ "system", new Dictionary<string, object>
{
{ "processor_count", Environment.ProcessorCount },
{ "os_version", Environment.OSVersion.ToString() },
{ "machine_name", Environment.MachineName },
{ "user_name", Environment.UserName }
}
}
};
_logger.LogInformation("System resources health check completed in {Duration}ms - Memory: {Memory}MB, Threads: {Threads}",
duration, Math.Round(totalMemory / 1024.0 / 1024.0, 1), threadCount);
// Definir thresholds para status
var memoryMb = totalMemory / 1024.0 / 1024.0;
if (memoryMb > 1000) // > 1GB
{
data["status"] = "degraded";
return Task.FromResult(HealthCheckResult.Degraded($"High memory usage: {memoryMb:F1}MB", data: data));
}
if (threadCount > 500)
{
data["status"] = "degraded";
return Task.FromResult(HealthCheckResult.Degraded($"High thread count: {threadCount}", data: data));
}
return Task.FromResult(HealthCheckResult.Healthy($"System resources normal (Memory: {memoryMb:F1}MB, Threads: {threadCount})", data: data));
}
catch (Exception ex)
{
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogError(ex, "System resources health check failed after {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return Task.FromResult(HealthCheckResult.Unhealthy($"System resources check failed: {ex.Message}", ex, data));
}
}
private static double GetCpuUsage(Process process)
{
try
{
return process.TotalProcessorTime.TotalMilliseconds;
}
catch
{
return 0;
}
}
private static string FormatUptime(TimeSpan uptime)
{
if (uptime.TotalDays >= 1)
return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalHours >= 1)
return $"{uptime.Hours}h {uptime.Minutes}m";
if (uptime.TotalMinutes >= 1)
return $"{uptime.Minutes}m {uptime.Seconds}s";
return $"{uptime.Seconds}s";
}
}

View File

@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace BCards.Web.Middleware
{
/// <summary>
/// Middleware para garantir que páginas que exibem conteúdo dependente de autenticação
/// tenham os headers de cache corretos para evitar problemas de cache do menu
/// </summary>
public class AuthCacheMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AuthCacheMiddleware> _logger;
public AuthCacheMiddleware(RequestDelegate next, ILogger<AuthCacheMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
await _next(context);
// Verificar se a resposta já começou antes de modificar headers
if (context.Response.HasStarted)
{
_logger.LogDebug("AuthCache: Response already started, skipping header modifications for {Path}",
context.Request.Path.Value);
return;
}
// Aplicar headers apenas para páginas HTML (não APIs, imagens, etc)
if (context.Response.ContentType?.StartsWith("text/html") == true)
{
var path = context.Request.Path.Value?.ToLower() ?? string.Empty;
// Páginas que sempre mostram menu com estado de autenticação
bool isPageWithAuthMenu = path == "/" ||
path.StartsWith("/home") ||
path == "/pricing" ||
path.StartsWith("/planos") ||
path.StartsWith("/admin") ||
path.StartsWith("/payment") ||
path.StartsWith("/subscription");
if (isPageWithAuthMenu)
{
// Se usuário está logado, garantir que não use cache
if (context.User?.Identity?.IsAuthenticated == true)
{
// Só adicionar se não foi definido explicitamente pelo controller
if (!context.Response.Headers.ContainsKey("Cache-Control"))
{
// Headers mais fortes para garantir que CDNs como Cloudflare não façam cache
context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate";
context.Response.Headers["Pragma"] = "no-cache";
context.Response.Headers["Expires"] = "0";
context.Response.Headers["Vary"] = "Cookie, Authorization";
_logger.LogDebug("AuthCache: Applied strong no-cache headers for authenticated user on {Path}", path);
}
}
else
{
// Para usuários não logados, garantir Vary: Cookie para cache adequado
if (!context.Response.Headers.ContainsKey("Vary") ||
!context.Response.Headers["Vary"].ToString().Contains("Cookie"))
{
var existingVary = context.Response.Headers["Vary"].ToString();
var newVary = string.IsNullOrEmpty(existingVary) ? "Cookie" : $"{existingVary}, Cookie";
context.Response.Headers["Vary"] = newVary;
_logger.LogDebug("AuthCache: Added Vary: Cookie for anonymous user on {Path}", path);
}
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More