Site de e-commerce farmacêutico completo, em HTML, CSS e JavaScript puros —
sem build, sem dependências, sem back-end. Basta abrir index.html.
O projeto foi criado a partir do pedido de "uma cópia de uma página de produto de farmácia para outra farmácia". Ele reproduz a estrutura e as convenções de uma loja farmacêutica brasileira (PDP com galeria, preço/PIX/parcelamento, cálculo de frete por CEP, abas de composição e bula, avaliações, carrinho), com marca, textos, ilustrações e dados totalmente originais.
Loja fictícia. Drogaria São Carlos, as marcas de produto (Lumiara, Nuvela, Solaris, Vitalis, Orallis, Soft Care), os registros sanitários, preços e avaliações foram inventados para esta demonstração. Nenhuma venda é processada e nenhum pagamento é cobrado.
| Arquivo | Conteúdo |
|---|---|
index.html |
Home: carrossel de destaques, categorias, ofertas do dia com cronômetro, prateleiras, faixa do clube |
produto.html |
Página de produto (PDP) — a peça central. Aceita ?sku= ou ?slug= |
categoria.html |
Listagem com filtros. Aceita ?cat=, ?q= (busca) e ?sub= |
carrinho.html |
Carrinho, cupom e frete |
checkout.html |
Checkout em 4 etapas: identificação, entrega, pagamento e confirmação |
conta.html |
Entrar, cadastrar, meus pedidos, meus dados, endereços e clube. Aceita ?aba= |
favoritos.html |
Produtos salvos, com adicionar todos ao carrinho |
institucional.html |
12 páginas de conteúdo, ajuda e políticas. Aceita ?p= |
Sem parâmetros, produto.html abre o produto principal
(?sku=LUM-CPH-250 — creme de pentear 250 ml).
- Galeria com miniaturas e zoom por clique
- Bloco de preço com preço "de/por", percentual de desconto, valor no PIX (5% off), parcelamento calculado e preço do clube de fidelidade
- Variações de tamanho que recalculam preço, PIX, parcelas e clube
- Estoque com três estados (disponível, últimas unidades, esgotado → botão "avise-me")
- Frete por CEP: 13 faixas de CEP, com entrega padrão, expressa (só onde há cobertura) e retirada em loja; frete grátis acima de R$ 99
- Abas: descrição, modo de uso, composição, tabela de especificações e avaliações
- Avisos regulatórios por tipo de produto — MIP ("ao persistirem os sintomas…"), suplemento ("este produto não é um medicamento"), protetor solar
- Avaliações com média agregada, distribuição por estrela e formulário que publica o comentário na hora
- Barra fixa de compra ao rolar, relacionados e vistos recentemente
- JSON-LD
schema.org/Productgerado para cada produto
O catálogo traz 26 itens. Os genéricos usam nome de substância, que é como genérico se identifica — paracetamol, omeprazol, loratadina, losartana.
Dois estados que uma farmácia precisa e uma loja genérica não tem:
- Venda sob prescrição (
receita: true). O item aparece na busca e nas categorias, mas não entra no carrinho: o botão vira Reservar para retirada, e a PDP explica que a receita é apresentada ao farmacêutico na loja. O bloqueio também vale noLoja.adicionar, para o caso de alguém chamar a função direto. Esses itens ficam fora da vitrine "Ofertas do dia", que é de compra por impulso. - Farmácia Popular (
farmaciaPopular: 'gratuito' | 'desconto'). Marca os medicamentos cobertos pelo programa, com selo no cartão e explicação na PDP ligando para a página do programa.
Tokens em :root (--mov-rapido, --mov-medio, --mov-lento e três curvas)
governam toda a animação; nenhum componente crava duração própria. Só
transform e opacity são animados — o navegador resolve na GPU, sem
recalcular layout.
- Revelação ao rolar (
assets/js/animacoes.js): umIntersectionObservermarca seções, cartões e painéis, com escalonamento de até seis passos dentro de cada grupo de irmãos. - Conteúdo novo entra sozinho. Um
MutationObserverno container cobre filtros, paginação e a carga vinda do banco, sem instrumentar cada página. - Cartão de produto amplia a imagem no hover, o coração pulsa ao favoritar e o contador do carrinho pulsa quando muda.
- Painéis que trocam — aba, acordeão, etapa do checkout, detalhe do pedido — entram com uma subida curta em vez de aparecer secos.
Três cuidados que o movimento exige:
- Nada pode ficar escondido. O estado inicial invisível só é aplicado com a
classe
js-ativo, que o próprio script adiciona; sem JS o conteúdo aparece normal. Há ainda uma rede de segurança que revela tudo após 3 s caso o observador falhe. - Cartão em prateleira horizontal fica de fora. Os que estão à direita nunca intersectam a janela no eixo X e continuariam invisíveis — quem ganha a entrada é a prateleira inteira.
prefers-reduced-motiondesliga tudo, inclusive a revelação, que passa a nascer visível.
- Quem gruda é o
#cabecalho, não o.cabecalhode dentro.position: stickyprende o elemento à caixa do pai. Com o sticky no filho, o pai media só a própria altura, e passados esses ~160px de rolagem o cabeçalho ia embora junto com ele. No#cabecalhoo pai é o<body>, que acompanha a página inteira. A barra de aviso fica fora dele, para subir e sumir. - Blocos laterais precisam de invólucro que estica. Filtros e resumo do
carrinho também não grudavam: o
divem volta encolhia até o tamanho deles e não sobrava curso.align-self: stretchno invólucro resolve. A caixa de compra da PDP não tem sticky de propósito — é mais alta que a coluna vizinha, então nunca haveria curso; quem assume ali é a.barra-fixa. - Âncoras usam
scroll-margin-topcom a altura corrente do cabeçalho, senão o alvo pára atrás dele. - Cabeçalho retrátil. Descendo, ele encolhe de 127px para 61px e esconde a
régua de categorias; subindo, volta inteiro na hora, sem precisar chegar ao
topo. A decisão é por direção, mas não pode ser tomada a cada quadro:
encolher o cabeçalho encurta o documento, o navegador dispara rolagem por
causa disso e a direção aparente se inverte, fazendo o estado piscar. Duas
defesas resolvem — um acumulador com limiar de 50px, para que um
deslocamento de poucos pixels nunca troque o estado, e a ressincronização da
referência depois que o layout assenta. A altura corrente fica na variável
CSS
--topo-fixo, usada pelos blocosstickypara não passarem por baixo. - Menu mobile. Abaixo de 860px a régua de categorias vira uma gaveta lateral com categorias, conta e ajuda, aberta pelo hambúrguer.
- Checkout em etapas. Identificação → entrega (com CEP, endereços salvos e escolha de frete) → pagamento (PIX, cartão ou boleto) → confirmação. O pedido é gravado e aparece em Meus pedidos, com detalhamento e recompra.
index.html produto.html categoria.html carrinho.html
checkout.html conta.html favoritos.html institucional.html
supabase/
migrations/ esquema e carga inicial do banco
assets/
css/estilos.css tokens de design, componentes e responsivo
js/dados.js catálogo local e configuração da loja
js/api.js carga do banco, com volta ao catálogo local
js/loja.js cabeçalho, rodapé, carrinho, favoritos, sessão, pedidos
js/home.js js/produto.js js/categoria.js js/carrinho.js
js/checkout.js js/conta.js js/favoritos.js js/institucional.js
img/ ilustrações SVG originais, logo e banners
Cabeçalho e rodapé são montados por loja.js em todas as páginas, então
mudanças neles valem para o site inteiro.
python3 -m http.server 8000
# http://localhost:8000Abrir os arquivos direto pelo file:// também funciona.
Quase tudo vive em dois lugares:
assets/js/dados.js— o objetoCONFIGno fim do arquivo tem nome da loja, CNPJ, farmacêutico responsável, telefone, regra de frete grátis, desconto do PIX, limite de parcelas e cupons. O arrayPRODUTOSé o catálogo; cada item aceita descrição, modo de uso, composição, tabela de especificações, aviso legal e avaliações.assets/css/estilos.css— o bloco:rootno topo concentra as cores. Trocar--marca-*e--oferta-*muda a identidade do site inteiro.
Logo e favicon estão em assets/img/logo.svg, logo-claro.svg e favicon.svg.
O catálogo vive hoje em assets/js/dados.js, mas o site já sabe ler de um
banco. Em supabase/migrations/ estão o esquema e a carga inicial:
| Arquivo | Conteúdo |
|---|---|
0001_schema.sql |
11 tabelas no schema farmacia, índices, triggers, RLS e privilégios |
0002_carga_inicial.sql |
7 categorias, 26 produtos, 17 lojas, 9 serviços, 3 cupons, 13 faixas de frete |
Decisões que valem saber:
- Dinheiro em centavos. Toda coluna monetária é
integer. Reais em ponto flutuante acumulam erro de arredondamento ao somar um carrinho, e89.90não é exatamente89.90em binário. As variações de produto, guardadas em JSONB, usam a mesma unidade. - Coluna
ordemnos produtos. A vitrine tem sequência curada. Sem ela a listagem sairia por SKU e embaralharia as prateleiras. - RLS em todas as tabelas. O catálogo é leitura pública; escrever nele só
com
service_role, que ignora RLS. Pedido é do dono e de mais ninguém. Avaliar exige conta — sem isso a caixa de comentários vira alvo de robô. - Conta e perfil. O login é do Supabase Auth;
clientesé o perfil (nome, CPF, telefone, clube), criado por trigger no cadastro, eenderecosguarda um endereço por CEP. Cada cliente só enxerga os próprios dados. - Pedidos são criados pelo servidor. O cliente só lê os próprios pedidos; criar
pedido e mudar status é com
service_role, numa Edge Function que recalcula preços e frete — o navegador não é confiável para dizer quanto custa o carrinho. É o mesmo ponto em que a cobrança entra (cobrar()emcheckout.js). - Item de pedido guarda nome e preço. O catálogo muda de preço; o pedido antigo tem de continuar mostrando o que o cliente pagou.
Para aplicar, preencha config.supabase em dados.js:
supabase: { url: 'https://SEU-PROJETO.supabase.co', chave: 'sb_publishable_...', schema: 'farmacia' }A chave publicável é pública por natureza — o que protege os dados é o RLS, não
o segredo da chave. Exponha o schema farmacia em Settings → API → Exposed
schemas do projeto.
assets/js/api.js cuida da carga: converte as linhas do banco para a forma que
as páginas esperam e substitui o catálogo em memória. Sem configuração, ou se a
rede falhar, o site segue com o catálogo local — avisa no console e não quebra.
O checkout está pronto, mas nenhum provedor foi integrado — nenhuma cobrança
acontece e nenhum dado de cartão sai do navegador. O ponto de integração está
isolado na função cobrar() em assets/js/checkout.js: é ali que a cobrança
será criada e o retorno do provedor (QR do PIX, 3DS do cartão, linha digitável
do boleto) tratado. O pedido já nasce com um campo status correspondente.
Carrinho, favoritos, CEP, produtos vistos, avaliações escritas, sessão do
cliente, pedidos e endereços ficam em localStorage (prefixo dsc:). Toda
leitura é protegida por try/catch, então o site funciona normalmente em
janela anônima.
HTML semântico, link "pular para o conteúdo", abas navegáveis por setas do
teclado, aria-live nos avisos e no resultado do frete, foco visível,
alvos de toque adequados e suporte a prefers-reduced-motion.
São oito suítes, somando 209 checagens. O esquema é validado contra um Postgres real: as duas migrations são aplicadas do zero e 12 checagens confirmam as políticas de acesso — visitante lê o catálogo, não lê pedido, não escreve no catálogo, não avalia sem conta. Um teste de mapeamento pega as linhas reais do banco e confirma que o catálogo resultante é idêntico ao estático, campo a campo. Outro derruba o banco de propósito para garantir que a loja abre mesmo assim.
A interface foi verificada com Playwright em 1360px e 390px, em duas suítes que somam mais de 100 checagens: renderização, busca, carrinho, variações de produto, frete (válido e inválido), filtros, ordenação, cupons, cabeçalho retrátil, menu mobile, favoritos, conta, checkout completo, gravação do pedido e ausência de rolagem horizontal no mobile em todas as páginas.