E-commerce oficial de la comunidad devsChile. Venta de productos exclusivos con pago integrado vía MercadoPago, panel de administración, gestión de imágenes y configuración dinámica desde base de datos.
Producción: tienda.devschile.cl · Admin: tienda.devschile.cl/admin
| Capa | Tecnología |
|---|---|
| Frontend | React 18 + TypeScript + Vite |
| Estilos | Tailwind CSS (paleta de marca devsChile) + Onest / Fira Mono |
| UI Components | Radix UI + shadcn/ui |
| Animaciones | Motion v12 (Framer Motion) — shared element, spring, stagger |
| Routing | React Router DOM v6 |
| BD | NeonDB — Postgres serverless |
| Backend | Netlify Functions (Node.js, CJS) |
| Imágenes | UploadThing v7 — CDN global |
| Pagos | MercadoPago SDK v2 — Checkout Pro (redirect) |
| Emails | Resend (staging) · Mailgun (producción) — multi-proveedor |
| Despliegue | Netlify (Frontend + Functions) |
npm install
npm install --prefix netlify/functions # deps de las funciones
cp .env.example .env # completar con credenciales reales# NeonDB — solo backend, nunca prefijo VITE_
NEON_DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
# MercadoPago
VITE_MERCADOPAGO_PUBLIC_KEY=APP_USR-... # frontend (seguro exponerse)
MERCADOPAGO_ACCESS_TOKEN=APP_USR-... # backend solo
MERCADOPAGO_WEBHOOK_SECRET=whsec_... # Panel MP → Tu app → Webhooks
# Identidad devsChile (soy) — descuento miembro por OAuth, no por email:
# el checkout pide autorización authorize → code → soy-exchange (secreto solo server-side).
# VITE_SOY_URL: origen de soy que ve el browser (default https://soy.devschile.cl);
# cámbialo si el checkout debe autorizar contra un soy de staging.
VITE_SOY_URL=https://soy.devschile.cl
# SOY_CLIENT_SECRET debe ser el MISMO que NUXT_OAUTH_CLIENTS_TIENDA_CLIENT_SECRET en soy.
SOY_MEMBERS_API_URL=https://soy.devschile.cl
SOY_CLIENT_SECRET=
MEMBER_DISCOUNT_PERCENT=10
# Email (cambiar solo EMAIL_PROVIDER para alternar)
EMAIL_PROVIDER=resend # resend | mailgun
RESEND_API_KEY=re_...
FROM_EMAIL=
MAILGUN_API_KEY=key-... # solo producción
MAILGUN_DOMAIN=mg.devschile.cl # solo producción
# UploadThing — imágenes de productos
UPLOADTHING_TOKEN=tu_token_aqui # dashboard.uploadthing.com → API Keys
# Admin panel
ADMIN_EMAIL=
ADMIN_PASSWORD=contraseña-segura
ADMIN_JWT_SECRET=string-aleatorio-64-chars # openssl rand -base64 48
# CORS y entorno
ALLOWED_ORIGINS=https://tienda.devschile.cl,https://devschile-tienda.netlify.app
SITE_URL=http://localhost:3000
NODE_ENV=developmentVer
docs/mercadopago-integration.mdpara guía completa de MercadoPago.
# Solo UI (mock data, sin funciones ni BD)
npm run dev
# Full stack (dos terminales)
npm run dev # Terminal 1 — Vite en :3000
npm run dev:functions # Terminal 2 — Netlify Functions en :9999
# Build producción
npm run build
# Migrar imágenes locales → UploadThing (one-shot)
npm run migrate:images
# Generar favicons desde el logo
npm run generate:faviconsEl proxy de Vite redirige /.netlify/functions/* y /admin-api/* → :9999 automáticamente.
Aplica en orden desde migrations/ en el SQL Editor de Neon:
| # | Archivo | Descripción |
|---|---|---|
| 01 | create_products_schema.sql |
Tablas products y product_images |
| 02 | seed_products_from_mock.sql |
Datos iniciales de ejemplo |
| 03 | add_stock_and_available.sql |
Stock + trigger available=false si stock=0 |
| 04 | rename_active_to_visible.sql |
active → visible |
| 05 | product_images_cover.sql |
is_cover + trigger single-cover por producto |
| 06 | create_orders.sql |
Tablas orders y order_items |
| 07 | add_on_sale.sql |
Badge de oferta en productos |
| 08 | add_wants_newsletter.sql |
Consentimiento de newsletter en orders |
| 09 | add_long_description_and_sale_price.sql |
Descripción larga (Markdown) + precio oferta |
| 10 | add_original_price_to_order_items.sql |
Precio original para emails con descuentos |
| 11 | add_notes_to_orders.sql |
Notas internas por orden (solo admin) |
| 12 | create_settings.sql |
Configuración dinámica de la tienda |
| 13 | add_archived_to_products_and_orders.sql |
Archivado reversible de productos y órdenes |
| 15 | add_bundle_and_addon_fields.sql |
Packs de stickers + add-ons (sin tocar productos actuales) |
| 16 | add_shipping_enabled_to_products.sql |
Envío opcional por producto (default: habilitado) |
| 17 | add_presale_to_products.sql |
Badge de preventa (⏳) — excluyente con on_sale |
| 18 | add_promo_codes.sql |
Códigos de descuento (%, monto fijo o envío gratis) + columnas de descuento en orders |
| 19 | fix_promo_codes_id_type.sql |
promo_codes.id uuid → text (prm_..., igual que products/images) |
| 20 | add_shipping_tier.sql |
Envío por niveles (XS/S/M/L): tier por producto + costo absoluto por tier en settings |
| 21 | add_bundle_item_ids.sql |
Packs curados: bundle_item_ids (JSON de ids) — cada pack declara qué ítems incluye |
products
id, name, description, long_description (Markdown)
category, price, sale_price, on_sale
presale ← badge de preventa (⏳), excluyente con on_sale (migración 17)
visible, available, stock
product_type (standard|bundle|addon) ← packs + stickers (migración 15)
selectable_in_bundles, bundle_unit_price
shipping_enabled (default true) ← envío opcional por producto (migración 16)
shipping_tier (xs|s|m|l, default 'xs') ← tamaño del paquete (migración 20)
bundle_sizes (JSON [3,4,6]), bundle_allow_surprise
bundle_item_ids (JSON de ids) ← ítems incluidos en cada pack (migración 21)
created_time
product_images
id, product_id, variant, position
url, filename, size, type
is_cover ← portada del card (trigger garantiza 1 sola por producto)
orders
id (uuid), status (pending|approved|rejected|pending_transfer|refunded|cancelled)
total_amount, customer_name, customer_email
shipping_address, shipping_city, shipping_region, shipping_zip
mp_preference_id, mp_payment_id
discount_code, discount_type (percent|fixed|shipping), discount_amount ← promo aplicada
wants_newsletter, notes, created_at, updated_at
order_items
id, order_id, product_id, product_name
quantity, unit_price, original_unit_price, subtotal
— product_id='shipping' identifica el ítem de envío
settings
key (PK), value, updated_at
— pares clave/valor editables desde /admin/settings
— envío: shipping_cost (base/legacy) + shipping_cost_xs/_s/_m/_l (costo
absoluto por tier, migración 20) + free_shipping_threshold
promo_codes
id (text PK `prm_...`), code (normalizada a mayúsculas), description
discount_type (percent|fixed|shipping), discount_value (%, CLP o referencia)
min_subtotal, max_discount (tope para %)
starts_at, expires_at, max_uses, uses_count
active, archived, created_time
— gestionados desde /admin/promos; el uso se suma en el webhook al aprobar
Usuario → Catálogo → Carrito → Checkout Form
↓
Orden PENDING en NeonDB
Preferencia en MercadoPago
Emails de intención (comprador + admin)
↓
Redirect → MercadoPago
↓
Usuario paga (tarjeta, transferencia)
↙ ↘
/success?order_id= /failure|pending?
↓ ↓
get-order (estado) get-order (estado)
↓ (asíncrono)
mercadopago-webhook
· Actualiza orders.status
· Descuenta stock si approved
(salta product_id='shipping')
· Emails de confirmación
↓ (respaldo)
reconcile-payments (cada 5 min)
· Órdenes pending/pending_transfer
se contrastan con la API de MP
· Auto-aprueba + descuenta stock
si el webhook se perdió/rechazó
Auto-aprobación: el estado de la orden y el descuento de stock se resuelven en una
única función de fulfillment (netlify/functions/lib/fulfill.js) usada por el webhook,
por get-order (cuando el comprador vuelve a /success) y por la reconcilier programada
reconcile-payments — así una orden aprobada pasa sola a approved y baja stock aunque
la notificación del webhook se pierda.
Costo de envío: cada producto clasifica el paquete que le cobra el courier (shipping_tier: XS | S | M | L, editable en el admin por producto). El costo de un pedido = costo absoluto del tier más grande presente (cuando se mezclan niveles, el más grande manda — no se suman). Los costos se configuran en /admin/settings → Envío (un monto por tier). Si aplica costo, se agrega como ítem product_id='shipping' (con el tier en el nombre, p. ej. "Envío a domicilio (S)") al array de ítems — así MercadoPago lo cobra y los emails lo muestran con breakdown separado. El servidor recalcula el tier y el costo siempre desde la base de datos (nunca confía en lo enviado por el cliente).
Los ítems de bajo valor (stickers, button pins, etc.) no justifican un envío propio, así que se venden dentro de packs o como agregados:
- Pack (bundle): producto
product_type='bundle'conbundle_unit_price(precio por ítem),bundle_sizes(p. ej.[3,4,6]),bundle_allow_surpriseybundle_item_ids(ids curados: la lista exacta de ítems que ese pack permite elegir). Su botón en el catálogo abre el constructor de packs (BundleBuilder): se elige tamaño y se combinan ítems del roster del pack (con stock). Cada pack tiene su propio roster — ya no se mezclan stickers con pins si el admin no los incluye. Si faltan ítems para completar el tamaño se muestra un aviso: con sorpresas, el usuario debe confirmar "acepto ítems sorpresa"; sin sorpresas, se bloquea hasta completar la selección. - Add-on (ítem de bajo valor): producto
product_type='addon'. No aparece en el catálogo salvo que el carrito ya acumule subtotal ≥ costo de envío (entonces se puede añadir al mismo pedido sin envío extra). - El flag
selectable_in_bundleses el "pool opt-in": solo los ítems marcados aparecen en la lista "Ítems incluidos" de cada pack en el admin. - El backend valida todo de nuevo en
create-payment.js: tamaño ∈bundle_sizes, selección = tamaño (explícitos + sorpresas), ítems dentro del roster del pack (bundle_item_ids) y con stock, precio recalculado desde la BD, y rechaza ítems add-on en pedidos cuyo subtotal no cubra el envío. Al aprobarse el pago, el webhook descuenta el stock de cada ítem elegido; los slots sorpresa se resuelven con stock disponible al despachar.
Se administran en /admin/promos. Un pedido aplica un solo código, validado SIEMPRE por el servidor al crear el pago (el checkout solo muestra el descuento estimado):
- Tipos:
percent(X% del subtotal, con tope opcionalmax_discount),fixed(monto CLP) oshipping(anula solo el costo de envío — no descuenta del subtotal). - Restricciones: subtotal mínimo, ventana
starts_at/expires_at,max_usesy toggleactive. - Uso de los códigos:
uses_countse incrementa en el webhook al pasar la orden aapproved(idempotente) — los pedidos abandonados enpendingno consumen usos. - Almacenamiento: las líneas de
order_itemsguardan el precio sin descuento (snapshot). El descuento vive enorders.discount_code/discount_type/discount_amount, y MercadoPago lo prorratea de forma exacta entre las líneas de producto (lib/discount.js). - Emails y confirmación: muestran la fila
Descuento (CÓDIGO) −CLPoEnvío gratis (CÓDIGO)según el tipo.
Las imágenes se almacenan en UploadThing CDN (ufs.sh) y sus URLs en la tabla product_images.
# Migrar imágenes existentes en public/products/ → UploadThing
npm run migrate:images
# Lee: public/products/{slug}/*.jpg
# Sube a UploadThing, actualiza product_images en NeonDB
# is_cover = imagen cuyo basename coincide con el folder nameDesde el admin (/admin/products → Editar) puedes:
- Ver todas las imágenes del producto en grilla 3×3
- Subir nuevas (drag & drop o file picker, máx 8 MB)
- Cambiar la portada (⭐ siempre visible)
- Eliminar (borra de UploadThing + BD)
- Hacer zoom con navegación ←→ y teclado
| Trigger | Destinatario | Template |
|---|---|---|
| Checkout (antes de pagar) | Comprador | Resumen carrito + link MP |
Webhook approved |
Comprador | Confirmación con detalle |
Webhook pending_transfer |
Comprador | Transferencia en proceso |
Webhook rejected |
Comprador | Pago fallido + reintentar |
| Checkout | Admin | Alerta nueva intención |
Webhook approved / pending_transfer |
Admin | Detalle completo de orden |
Los emails muestran precio original tachado + precio oferta en descuentos, y breakdown subtotal / envío / total cuando hay costo de envío.
Cambiar proveedor: solo cambia EMAIL_PROVIDER=resend|mailgun en Netlify.
Acceso mediante JWT con credenciales de las variables de entorno (ADMIN_EMAIL, ADMIN_PASSWORD). El token expira en 12h.
| Sección | Ruta | Funcionalidad |
|---|---|---|
| Dashboard | /admin |
Stats por periodo (hoy/7d/30d/6m/todo), últimas órdenes, stock bajo |
| Productos | /admin/products |
Tabla con skeleton, toggles inline (visible/disponible/oferta/preventa), filtros, crear, editar, gestión de imágenes, exportar CSV |
| Pedidos | /admin/orders |
Tabs por estado, cambio de estado con confirmación, notas internas, detalle con dirección de envío copiable y breakdown de totales, exportar CSV |
| Códigos | /admin/promos |
Códigos de descuento (% / monto fijo / envío gratis), vigencia, límites de uso, toggle activo, exportar CSV |
| Configuración | /admin/settings |
store_name, tagline, email contacto, modo mantenimiento, envío (habilitado/costo/umbral gratis), status integraciones |
Las tablas incluyen skeleton animado (Motion) en la carga y stagger spring en la entrada de filas.
El panel lateral de /admin/orders separa Cliente de Envío. El bloque de envío muestra la dirección en líneas (calle / comuna–región / código postal) con un botón Copiar que deja en el portapapeles el formato de etiqueta de courier (nombre + dirección, una línea por campo). El tier cobrado —que en la base viaja dentro del nombre del ítem, p. ej. Envío a domicilio (M)— se extrae y se muestra como badge. Cuando el pedido no lleva despacho, el bloque lo dice explícitamente ("Retiro — sin envío a domicilio") en vez de omitirse, y si el envío salió gratis por umbral (no hay ítem shipping) se marca con el chip "sin costo".
/
├── actions/
│ ├── createPayment.ts # CartItem[] + CustomerData → MercadoPago
│ ├── getOrder.ts # Consulta estado de orden por ID
│ ├── loadProducts.ts # Carga productos desde NeonDB (fallback mock)
│ └── loadSettings.ts # Configuración pública desde NeonDB (con fallback)
├── admin/ # Panel de administración (/admin)
│ ├── AdminApp.tsx # Rutas relativas + auth guard
│ ├── components/ # AdminLayout, AdminSidebar, OrderDetailPanel,
│ │ # ProductEditPanel, ImageManager, TableSkeleton, Toggle...
│ ├── hooks/ # useAdminAuth, useAdminData, useAdminTitle, useRowSelection
│ ├── pages/ # DashboardPage, ProductListPage, OrderListPage,
│ │ # SettingsPage, LoginPage
│ └── utils/adminFetch.ts # Fetch autenticado con JWT
├── app/
│ ├── app.tsx # Componente principal (catálogo, settings, carrito)
│ └── productsMock.ts # Mock data para desarrollo local
├── components/
│ ├── CartDrawer.tsx # Carrito lateral (Motion spring)
│ ├── CheckoutModal.tsx # Form de datos + shipping props
│ ├── ProductCard.tsx # Card con precio, oferta, badge, glow button
│ ├── ProductImageModal.tsx # Lightbox 2 columnas (shared element Motion)
│ ├── InfoModal.tsx # "Sobre la tienda" con stagger
│ ├── OrderConfirmation.tsx # Páginas success/failure/pending
│ ├── CoinConfetti.tsx # Confetti canvas-confetti en success
│ ├── MarkdownText.tsx # Renderer Markdown sin dependencias
│ ├── DevTools.tsx # Panel de dev (solo DEV mode)
│ └── ui/ # shadcn/ui (button, dialog, toast...)
├── data/
│ └── comunas-chile.ts # 346 comunas / 16 regiones de Chile
├── docs/
│ └── mercadopago-integration.md
├── hooks/
│ ├── useCart.ts # Estado del carrito (localStorage)
│ └── useStoreSettings.ts # Settings desde NeonDB con helpers parseados
├── migrations/ # 20 archivos SQL secuenciales para NeonDB
├── netlify/
│ └── functions/
│ ├── admin-api.js # Router CRUD admin (JWT) — products, orders, images,
│ │ # upload, settings, dashboard
│ ├── admin-auth.js # POST login → JWT (timingSafeEqual, delay en fallo)
│ ├── create-payment.js # Crea orden + preferencia MP + emails intención
│ ├── get-order.js # Consulta orden por ID
│ ├── get-products.js # Catálogo público desde NeonDB
│ ├── get-settings.js # Configuración pública (cache 60s, fallback)
│ ├── mercadopago-webhook.js # Webhook → actualiza estado + stock + emails
│ ├── emails/ # Templates HTML + providers (Resend/Mailgun)
│ └── package.json # mercadopago, neon, resend, mailgun, uploadthing
├── public/
│ ├── products/ # Imágenes originales (backup — CDN en UploadThing)
│ ├── favicon-*.png
│ ├── apple-touch-icon.png
│ ├── social.jpg # Imagen Open Graph (600×315)
│ └── site.webmanifest
├── scripts/
│ ├── generate-favicons.mjs # npm run generate:favicons
│ └── migrate-images-to-uploadthing.mjs # npm run migrate:images
├── src/
│ ├── main.tsx # BrowserRouter + rutas lazy (tienda + admin)
│ ├── index.css # Tailwind + custom CSS (glow buttons, etc.)
│ └── pages/ # SuccessPage, FailurePage, PendingPage, TerminosPage
├── types/
│ └── products.ts # ProductRecord, ProductFields, CartItem...
├── .env.example # Plantilla de variables (sin valores reales)
├── netlify.toml # Build + headers seguridad + caché + noindex /admin*
└── tailwind.config.js # Paleta de marca + tipografía
| Token | Hex | Uso |
|---|---|---|
brand-primary |
#b45b38 |
Botones, CTAs, precios |
brand-secondary |
#85422b |
Títulos, bordes |
brand-accent |
#d4a373 |
Detalles, hovers |
brand-background |
#fdfaf8 |
Fondo general |
brand-surface |
#f5ece4 |
Cards, secciones |
devs-text |
#2d1a12 |
Texto principal |
devs-muted |
#7a6b63 |
Texto secundario |
- Onest — textos, body, labels
- Fira Mono — títulos (h1–h4),
DialogTitle, precios destacados
Tienda:
- Grid de productos: stagger + FLIP al filtrar por categoría
- Cards: shared element image (card → modal lightbox),
whileHoverspring - Modal lightbox: slide entre imágenes con dirección, dots pill animados
- Carrito: slide spring desde la derecha, items
AnimatePresence popLayout - Checkout: stagger de campos, height reveal del bloque de envío, shake en validación
- Modales de info: backdrop blur + spring easing
Admin:
- Tablas: skeleton pulsante que replica la estructura de columnas → fade-out → stagger spring de filas de datos
- Dashboard: skeleton cards, números con flip vertical al cambiar periodo,
layoutIden selector de periodo ProductEditPanel/OrderDetailPanel: slide desde la derecha + fade backdropImageManager: shared elementlayoutId(thumbnail → modal zoom), 3D tilt en hover, burst ⭐ al cambiar portada
- CORS whitelist en todas las Netlify Functions
- Validación y sanitización de inputs en el backend
- Precio, stock, envío y descuento SIEMPRE recalculados en el servidor desde la BD — nunca se confía en lo que manda el cliente
- Los packs solo se compran por su flujo (ítem con
bundle, validado contra el roster): enviados como ítem plano se rechazan, para que no se cobren alpriceplaceholder - Headers de seguridad en
netlify.toml—X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy X-Robots-Tag: noindex, nofollowen todas las rutas/admin*- Caché inmutable para assets hasheados de Vite (
/assets/*) Cache-Control: no-storeen endpoints de admin- Credenciales MP solo en env vars del servidor (nunca en el bundle del cliente)
- Firma HMAC-SHA256 para validar webhooks de MercadoPago
- Webhook idempotente — órdenes ya aprobadas no se reprocesam
- Admin JWT:
crypto.timingSafeEqual()en comparación de credenciales + delay de 500ms en fallos
Para consultas de la tienda: huemul@devschile.cl
Proyecto privado — © 2026 Tienda devsChile. Todos los derechos reservados.