Voltar ao blog
Tutorial

Galeria antes/depois pra odontologia: compliance LGPD + UX que converte

Por Flávio Emanuel · · 12 min de leitura

Galeria antes/depois converte. Paciente vê aquele sorriso lindo depois e pensa “quero meu sorriso assim”. 40-60% de aumento em conversão é comum.

Problema: odontologia é sensível. Mostrar foto de paciente é risco legal. LGPD diz que você precisa de consentimento explícito por escrito. Se não tiver, leva multa (até R$ 50 milhões, segundo a lei).

Achei solução que funciona: termo de consentimento específico pra galeria, blur nas partes identificáveis (olhos, tatuagens), slider antes/depois em React que carrega lazy (pra não entupir a página).

Resultado: galeria que converte, sem dor de cabeça legal.

LGPD artigo 7 diz que você precisa de “consentimento específico e informado” pra processar dados pessoais. Foto do rosto é dado pessoal (biometria).

Na prática:

  • Foto anônima de sorriso? Tá ok, mas é cinzento.
  • Foto de rosto completo sem consentimento? Multa.
  • Foto de rosto COM consentimento escrito? Tranquilo.

O consentimento tem que ser:

  1. Escrito (e-mail não conta, tem que ser documento assinado)
  2. Específico (não é “você consente com tudo”, é “você consente sua foto ser usada na galeria do site”)
  3. Gratuito (sem coerção ou promessa de desconto)
  4. Informado (paciente sabe exatamente o que tá permitindo)

Termo de consentimento (modelo pronto)

Aqui tá um termo que já passei por advogado e funciona:

TERMO DE CONSENTIMENTO PARA USO DE IMAGEM
CLÍNICA [NOME]

Eu, [NOME DO PACIENTE], portador(a) de RG nº [___] e CPF nº [___], residente e domiciliado(a) à [ENDEREÇO COMPLETO], por este instrumento, consinto expressamente que a Clínica [NOME] ("Clínica") utilize minhas fotografias intra e/ou extraorais, tiradas durante meu tratamento, para fins de divulgação de cases clínicos em seu website, redes sociais e materiais de marketing, sob as seguintes condições:

1. CONSENTIMENTO ESPECÍFICO
Autorizo o uso de minhas imagens exclusivamente para:
a) Galeria de casos clínicos do website (antes/depois)
b) Portfólio profissional em redes sociais da Clínica
c) Apresentações e materiais didáticos internos da Clínica

2. ANONIMATO E PRIVACIDADE
Entendo que:
a) Minhas imagens serão alteradas para proteger minha identidade (blur de olhos, ausência de dados identificáveis)
b) Meu nome NÃO será divulgado
c) Nenhum dado pessoal será associado às imagens

3. VIGÊNCIA
Este consentimento é válido a partir da data de assinatura e perdura indefinidamente, a menos que eu revogue por escrito.

4. REVOGAÇÃO
Posso revogar este consentimento a qualquer momento, mediante comunicação escrita à Clínica. Após revogação, nenhuma imagem será mais utilizada, porém as já divulgadas podem permanecer online.

5. CLÁUSULA DE ISENÇÃO
Concordo que a Clínica não me deve royalties ou compensação adicional pelo uso de minhas imagens para os fins descritos.

Data: _____ / _____ / _____

Assinado digitalmente por: [ASSINATURA ELETRÔNICA]
(Plataforma de assinatura: DocuSign / Adobe Sign / similar)

---
CLÍNICA [NOME]
Por: [DENTISTA - RESPONSÁVEL]
CPF: [___]

Armazena esse documento assinado na sua nuvem (Google Drive protegido com senha, Supabase com encryption). Nunca perde.

Para assinatura eletrônica, use DocuSign ou Adobe Sign. Não use WhatsApp. Não vale.

Estrutura de pasta e armazenamento

Quando paciente assina consentimento, você já fotografa com padrão:

/galeria-odonto/
├── paciente-001/
│   ├── consentimento.pdf (assinado)
│   ├── antes_frontal_1.jpg (original, alta res)
│   ├── antes_lateral_1.jpg
│   ├── depois_frontal_1.jpg
│   └── depois_lateral_1.jpg
├── paciente-002/
│   └── ...

Nunca coloca arquivo na mesma pasta que fica pública. Depois usa script pra:

  1. Redimensionar pra web (max 1200px)
  2. Desfocar partes sensíveis (olhos, tatuagens)
  3. Comprimir agressivamente (80% quality JPG)
  4. Copiar pra pasta pública no CDN
#!/bin/bash
# script resize-and-blur.sh

INPUT_DIR="/galeria-privada"
OUTPUT_DIR="/galeria-publica"

for paciente_dir in $INPUT_DIR/paciente-*; do
  paciente_name=$(basename $paciente_dir)
  mkdir -p $OUTPUT_DIR/$paciente_name

  for img in $paciente_dir/*.jpg; do
    filename=$(basename $img)
    
    # Resize + compress
    ffmpeg -i "$img" -vf "scale=1200:-1" \
      -q:v 4 "$OUTPUT_DIR/$paciente_name/$filename"
    
    # Blur olhos (aproximado, você ajusta por quadrante)
    convert "$OUTPUT_DIR/$paciente_name/$filename" \
      -region 200x100+150+50 -blur 0x20 \
      "$OUTPUT_DIR/$paciente_name/$filename"
  done
done

Se não quer script, usa ImageMagick online ou Photoshop pra blur manual.

Componente Astro Image + lazy load

Astro Image tira o peso de imagem grande. Usa geração de imagens no build:

---
// src/components/GalleryBeforeAfter.astro
import { Image } from 'astro:assets';
import beforeImg from '../assets/case-001-before.jpg';
import afterImg from '../assets/case-001-after.jpg';

interface Props {
  beforeImage: ImageMetadata;
  afterImage: ImageMetadata;
  title: string;
  procedure: string;
}

const { beforeImage, afterImage, title, procedure } = Astro.props;
---

<div class="gallery-item" data-title={title}>
  <div class="before-after-slider">
    <div class="before">
      <Image 
        src={beforeImage} 
        alt={`Antes: ${procedure}`}
        width={600}
        height={400}
        loading="lazy"
        decoding="async"
      />
      <span class="label">Antes</span>
    </div>
    <div class="after">
      <Image 
        src={afterImage} 
        alt={`Depois: ${procedure}`}
        width={600}
        height={400}
        loading="lazy"
      />
      <span class="label">Depois</span>
    </div>
  </div>
  <p class="procedure-name">{procedure}</p>
</div>

<style>
  .gallery-item {
    margin: 2rem 0;
  }

  .before-after-slider {
    position: relative;
    overflow: hidden;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    max-width: 600px;
  }

  .before,
  .after {
    position: relative;
  }

  .label {
    position: absolute;
    top: 10px;
    right: 10px;
    background: rgba(0, 0, 0, 0.6);
    color: white;
    padding: 6px 12px;
    border-radius: 4px;
    font-size: 12px;
    font-weight: 600;
  }

  .procedure-name {
    margin-top: 0.75rem;
    font-size: 14px;
    color: #666;
    font-weight: 500;
  }
</style>

Isso renderiza a imagem em múltiplos formatos (WebP, AVIF) e tamanhos no build. Navegador baixa a versão mais otimizada.

Slider antes/depois em React

O slider dinâmico (deixa user arrastar) é React island:

---
// src/pages/galeria.astro
import BeforeAfterGallery from '../components/BeforeAfterGallery';

// Lista de cases (importa da DB ou JSON)
const cases = [
  {
    id: 1,
    title: 'Implante Frontal',
    procedure: 'Implante dental unitário',
    beforeImage: '/img/case-001-before.jpg',
    afterImage: '/img/case-001-after.jpg'
  },
  {
    id: 2,
    title: 'Clareamento',
    procedure: 'Clareamento dental',
    beforeImage: '/img/case-002-before.jpg',
    afterImage: '/img/case-002-after.jpg'
  }
];
---

<html>
  <body>
    <BeforeAfterGallery cases={cases} client:only="react" />
  </body>
</html>

Componente React:

// src/components/BeforeAfterGallery.tsx
import React, { useState, useRef } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';

interface CaseItem {
  id: number;
  title: string;
  procedure: string;
  beforeImage: string;
  afterImage: string;
}

interface Props {
  cases: CaseItem[];
}

export default function BeforeAfterGallery({ cases }: Props) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [sliderValue, setSliderValue] = useState(50);
  const imageContainerRef = useRef<HTMLDivElement>(null);

  const currentCase = cases[currentIndex];

  const goToPrevious = () => {
    setCurrentIndex((prev) => (prev === 0 ? cases.length - 1 : prev - 1));
    setSliderValue(50);
  };

  const goToNext = () => {
    setCurrentIndex((prev) => (prev === cases.length - 1 ? 0 : prev + 1));
    setSliderValue(50);
  };

  const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSliderValue(parseFloat(e.target.value));
  };

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <h2 className="text-3xl font-bold mb-2">Galeria de Cases</h2>
      <p className="text-gray-600 mb-8">
        Deslize para comparar os resultados de nossos tratamentos
      </p>

      {/* Slider container */}
      <div className="relative mb-8">
        <div
          ref={imageContainerRef}
          className="relative w-full overflow-hidden rounded-lg shadow-xl"
          style={{ paddingBottom: '66.67%' }}
        >
          {/* Before image (background) */}
          <img
            src={currentCase.beforeImage}
            alt={`Antes: ${currentCase.procedure}`}
            className="absolute inset-0 w-full h-full object-cover"
            loading="lazy"
          />

          {/* After image (on top) */}
          <div
            className="absolute inset-0 overflow-hidden"
            style={{ width: `${sliderValue}%` }}
          >
            <img
              src={currentCase.afterImage}
              alt={`Depois: ${currentCase.procedure}`}
              className="absolute inset-0 w-full h-full object-cover"
              style={{ width: `${(100 / sliderValue) * 100}%` }}
              loading="lazy"
            />
          </div>

          {/* Slider handle */}
          <input
            type="range"
            min="0"
            max="100"
            value={sliderValue}
            onChange={handleSliderChange}
            className="absolute inset-0 w-full h-full cursor-col-resize opacity-0 z-10"
            style={{ width: '100%', height: '100%' }}
          />

          {/* Visual handle line */}
          <div
            className="absolute top-0 bottom-0 w-1 bg-white shadow-lg z-5 pointer-events-none"
            style={{ left: `${sliderValue}%` }}
          >
            <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
              <div className="bg-white rounded-full p-2 shadow-lg">
                <ChevronLeft className="w-4 h-4 text-blue-600" />
                <ChevronRight className="w-4 h-4 text-blue-600" />
              </div>
            </div>
          </div>

          {/* Labels */}
          <span className="absolute top-4 left-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm font-semibold">
            Antes
          </span>
          <span className="absolute top-4 right-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded text-sm font-semibold">
            Depois
          </span>
        </div>
      </div>

      {/* Case info */}
      <div className="mb-6">
        <h3 className="text-xl font-bold mb-2">{currentCase.title}</h3>
        <p className="text-gray-700">{currentCase.procedure}</p>
      </div>

      {/* Navigation */}
      <div className="flex items-center justify-between mb-8">
        <button
          onClick={goToPrevious}
          className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
        >
          <ChevronLeft className="w-5 h-5" />
          Anterior
        </button>

        <div className="flex gap-2">
          {cases.map((_, index) => (
            <button
              key={index}
              onClick={() => {
                setCurrentIndex(index);
                setSliderValue(50);
              }}
              className={`w-2 h-2 rounded-full transition ${
                index === currentIndex ? 'bg-blue-600 w-8' : 'bg-gray-300'
              }`}
              aria-label={`Go to case ${index + 1}`}
            />
          ))}
        </div>

        <button
          onClick={goToNext}
          className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
        >
          Próximo
          <ChevronRight className="w-5 h-5" />
        </button>
      </div>

      {/* Counter */}
      <p className="text-center text-sm text-gray-600">
        Caso {currentIndex + 1} de {cases.length}
      </p>
    </div>
  );
}

Esse slider:

  • Carrega lazy (só quando entra na viewport)
  • Funciona touch (mobile)
  • Não carrega JS até usuário interagir (React island)
  • Performance: <50ms pra arrastar

Schema.org para casos clínicos

Google Rich Results reconhecem MedicalProcedure schema. Coloca no metadata:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "MedicalProcedure",
  "name": "Implante Dental",
  "description": "Implante dental unitário de titânio com carga imediata",
  "procedureType": "Implantology",
  "image": [
    "https://clinica.com/img/implante-depois.jpg"
  ],
  "doctor": {
    "@type": "Person",
    "name": "Dr. [Nome do Dentista]",
    "jobTitle": "Dentista"
  },
  "medicalSpecialty": "Odontologia",
  "result": "Restauração completa da função e estética dental",
  "riskFactor": "Minimal, com acompanhamento pós-operatório",
  "beforeImage": "https://clinica.com/img/implante-antes.jpg",
  "afterImage": "https://clinica.com/img/implante-depois.jpg"
}
</script>

Isso melhora ranking local + Rich Snippets no Google.

Code splitting automático no Astro

Astro adora code splitting. Se você importa o componente React como island:

import BeforeAfterGallery from '../components/BeforeAfterGallery';

<BeforeAfterGallery cases={cases} client:only="react" />

Astro:

  1. Renderiza página em HTML estático
  2. Cria arquivo JS separado só com o React
  3. Carrega JS só quando componente entra na viewport (intersection observer automático)

Resultado: página de galeria renderiza em 200ms sem JS. Slider carrega 3s depois (assincronamente).

Termo LGPD em ação (checklist)

Quando você foto um paciente:

  1. Tira foto (frente, lateral, 3/4, sorriso, oclusão)
  2. Mostra resultado ao paciente: “Posso usar essa foto na galeria?”
  3. Se sim, envia DocuSign com termo (leva 2 minutos)
  4. Paciente assina digitalmente
  5. Você baixa PDF assinado, guarda em pasta privada (Google Drive + password)
  6. Processa imagem: resize, blur de olhos/tatuagens, comprime
  7. Upload pra galeria pública

Tudo junto: 10 minutos por case.

Erros que cometi

Achei que pixel art blur no olho era engraçado. Paciente achou estranho. Agora uso Gaussian Blur (mais natural).

Tentei animar o slider com 60fps. Firefox ia a 30fps. Agora uso CSS transforms (hardware accelerated).

Coloquei 80 imagens na página de uma vez. Carregou em 5 segundos. Agora é 6 images per page + lazy load.

Esqueci de EXIF metadata nas fotos. As imagens tinham data/hora. Tive que re-upload. Agora removo com ImageMagick antes.

Checklist implementação

  • Criar termo de consentimento (passar com advogado odontológico)
  • Preparar DocuSign ou Adobe Sign (configurar branding)
  • Tirar fotos de 5 cases com consentimento assinado
  • Resize + blur com ImageMagick (script ou manual)
  • Upload pra CDN (Cloudflare, S3)
  • Criar componente Astro Image com lazy load
  • Criar componente React BeforeAfterGallery com slider
  • Adicionar schema.org MedicalProcedure
  • Testar performance (Lighthouse)
  • Testar mobile: iOS Safari, Chrome Android
  • Setup backup de consentimentos (Google Drive criptografado)
  • Documentar processo (pra quando contratar assistente)

Leia também: Local SEO pra clínica: Google Meu Negócio + schema.org | Agendamento online pra clínica em Astro + Supabase | Anatomia de landing page que converte

Conclusão

Galeria antes/depois é o elemento que mais converte num site de clínica. Paciente vê resultado, quer aquilo pra ele.

Mas antes/depois exige compliance legal. Termo assinado é obrigatório. Blur de identificação é recomendado (proteção extra).

Técnico fica simples com Astro Image + React slider + lazy load. Carrega rápido, mobile-first, acessível.

Combine tudo: termo LGPD + imagem otimizada + slider suave + schema.org. Você tem galeria que:

  • Converte pacientes (visual)
  • Tá legal (compliance)
  • Não trava o site (performance)
  • Ranqueia no Google (schema)

Implementa agora. Comece com 5 cases. Depois expande.

Próximo passo

Precisa de um dev que entrega de verdade?

Seja pra um projeto pontual, reforço no time, ou parceria de longo prazo. Vamos conversar.

Falar no WhatsApp

Respondo em até 2h durante horário comercial.