Svelte Commerce
A standalone open-source storefront built with SvelteKit, headless across 26 commerce backends. MIT licensed.
Tours and homes in the Mexican Caribbean
CommerceTypeScript · PLpgSQLGitHub @abrahag40OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Motor de reservas para tours en el Caribe y renta de inmuebles. Dos inventarios que se compran igual y se operan distinto, con un solo checkout: se cobra un anticipo en línea y el saldo se paga en destino.
Estado: el producto está completo y verificado de extremo a extremo (Sprints 0 a 7): se reserva, se cobra el anticipo, se opera el día a día, se resuelven cancelaciones y cambios de fecha, se publica catálogo desde el panel, y los avisos salen por correo y WhatsApp con monitoreo.
Lo que impide vender no es código, son tres cuentas del cliente: Stripe,
el dominio de correo autenticado y el número de WhatsApp con sus plantillas
aprobadas por Meta. La lista del día del despliegue está en
docs/puesta-en-produccion.md.
CLAUDE.md —
cómo levantarlo, qué no se negocia y las trampas que ya costaron caro.docs/plan-de-entrega.mddocs/arquitectura.md ·
dónde vive el sistemadocs/esquema.mddocs/puesta-en-produccion.md · docs/operacion.md (qué hacer cuando algo falla) · docs/manual-del-panel.md (para el equipo del cliente)sprint-01.md · sprint-02.md · sprint-03.md · sprint-04.md · sprint-05.md · sprint-06.md · sprint-07.mdSe necesita PostgreSQL 16 o superior, Node 22 y psql en el PATH.
cp .env.example .env # y apuntar DATABASE_URL a tu base
npm install
npm run db:migrate # aplica las migraciones pendientes
npm run db:seed # datos de desarrollo
npm run dev # el sitio en http://localhost:3000
Verificación, que es lo mismo que corre el pipeline:
npm run db:test # garantías del inventario (en transacción, sin dejar rastro)
npm run test:integration # capa de acceso: traducción de errores del dominio
npm run typecheck
npm run lint
NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3100 npm run build
npx next start -p 3100 &
BASE_URL=http://127.0.0.1:3100 ./scripts/smoke.sh # criterios sobre el sitio construido
BASE_URL=http://127.0.0.1:3100 npm run test:e2e # el checkout, en navegador real
BASE_URL=http://127.0.0.1:3100 npm run test:e2e:admin # un día de operación en el panel
BASE_URL=http://127.0.0.1:3100 npm run test:e2e:sme # el día que cierran el puerto
BASE_URL=http://127.0.0.1:3100 npm run test:e2e:publicar # publicar un tour sin ayuda
BASE_URL=http://127.0.0.1:3100 npm run audit # accesibilidad (axe) y peso de las páginas
npm run db:bench # prueba de carga: sobreventa bajo concurrencia
NEXT_PUBLIC_SITE_URL se fija al construir, no al arrancar: Next reemplaza
esas variables durante la compilación. La pasarela recibe una URL de retorno
absoluta, así que si no coincide con el puerto donde se sirve, el navegador
vuelve de pagar a un servidor que no existe.
npm run db:reset recrea todo desde cero. Se niega a correr contra una URL que
no sea local.
db/
migrations/ SQL escrito a mano, en orden. Fuente de verdad del esquema.
seed/ datos de desarrollo
tests/ pruebas de las garantías y de concurrencia
src/
app/[locale]/ rutas públicas; el prefijo de idioma es parte de la URL
app/admin/ panel de operación; sin prefijo de idioma y sin indexar
app/media/ entrega de las fotos subidas (no van en public/, ver abajo)
components/ componentes de la vitrina
db/ cliente, tipos propios y esquema generado por introspección
i18n/ idiomas, segmentos traducidos y etiquetas de interfaz
modules/ módulos de dominio (frontera explícita entre ellos)
scripts/ ciclo de vida de la base, verificación y generación
docs/ arquitectura, esquema, plan de entrega y decisiones
/es, /en), con el segmento de
colección traducido (/es/estancias/… ↔ /en/stays/…). Si las dos versiones
compartieran dirección, solo una posicionaría en buscadores — y el tráfico
orgánico en inglés es justo el que evita pagar comisión a un intermediario.canonical y hreflang se emiten absolutos: los buscadores ignoran
hreflang relativo. Verificado en scripts/smoke.sh, no supuesto.Las garantías del negocio están declaradas en la base, no verificadas en la aplicación, y eso necesita SQL que ningún generador expresa: restricciones de exclusión sobre rangos de fechas, bloqueos de fila, funciones transaccionales.
El flujo es al revés de lo habitual: el SQL manda y los tipos se generan.
npm run db:pull introspecta la base y reescribe src/db/generated/, así el
código no puede desviarse del esquema real. scripts/patch-generated.mjs
corrige después las dos cosas que drizzle-kit no sabe leer (daterange y los
DEFAULT que llaman funciones de Postgres); es idempotente y avisa si algún
día deja de haber algo que parchar.
No se usan las migraciones de drizzle-kit: el script las descarta para que no compitan con las escritas a mano.
Son mecanismos distintos porque los dos inventarios se agotan distinto:
| Estancias | Tours | |
|---|---|---|
| Se agota por | traslape de rangos de fechas | conteo de lugares |
| Garantía | EXCLUDE USING gist (unit_id =, stay &&) | CHECK (seats_taken <= capacity) |
| Orden entre peticiones | lo da la restricción de exclusión | SELECT … FOR UPDATE |
| Verificado en | db/tests/guarantees.sql 1–4 | db/tests/guarantees.sql 5–7 |
Ninguna de las dos depende de que la aplicación se acuerde de revisar. Están
probadas con clientes concurrentes de verdad (npm run db:bench), no solo en
secuencia:
A) 200 intentos de 40 clientes sobre 20 lugares → 20 apartados, 180 rechazados, drift 0
B) 80 intentos de 40 clientes, mismo rango → 1 apartado
C) 40 clientes, rangos contiguos → 40 apartados, 0 falsos conflictos
La base los emite como SQLSTATE propios y src/modules/availability/holds.ts
los traduce a InventoryUnavailableError con un mensaje que se le puede
mostrar al huésped.
| Código | Significado |
|---|---|
AM001 | cupo agotado en la salida |
AM002 | fechas ya ocupadas en la unidad |
AM003 | transición de estado inválida |
Tres reglas que vienen del SME y no del esquema:
Los reembolsos se registran en la base a la espera de que la pasarela ejecute el movimiento, igual que los cobros.
src/modules/payments y src/modules/notifications hablan con Stripe y Resend
por HTTP, detrás de una interfaz. Sin llaves configuradas se usan proveedores
locales que no son dobles de prueba: firman y verifican con el mismo
mecanismo que los reales, y el aviso se guarda renderizado en la bandeja en lugar
de enviarse. El camino que se ejercita —firma, idempotencia, confirmación
transaccional, reintentos— es el de producción; lo único que no ocurre es el
cobro y el envío.
Con STRIPE_SECRET_KEY y STRIPE_WEBHOOK_SECRET presentes se usa Stripe. La
selección es por configuración y no por una bandera de "modo desarrollo": sin
llaves no hay nada que cobrar, y con llaves siempre se usa la real.
El huésped que reserva por WhatsApp espera la confirmación por WhatsApp; un correo se le pierde entre las promociones. Se manda además del correo, nunca en su lugar: el correo lleva el desglose, la política y el depósito de garantía; WhatsApp lleva lo que se lee en la pantalla de bloqueo. Si un canal falla, el otro sigue.
Dos cosas que condicionan el diseño:
src/modules/notifications/whatsapp.ts en el formato exacto
del trámite: ese archivo es lo que se copia a la consola de Meta.whatsapp_number), no en la aplicación. El
encolado ocurre dentro de la misma transacción que confirma o cancela la
reserva, igual que el correo desde el Sprint 3; tener la normalización en los
dos lados garantiza que algún día difieran.POST /api/jobs/tick es el latido: expira apartados, encola recordatorios,
vacía la bandeja de salida y genera las variantes de las fotos. Lo llama un cron
cada minuto con la cabecera x-job-secret.
Falla en silencio, y ese es el problema: sin latido nada da error,
simplemente los apartados no expiran y los avisos no salen. Por eso el tick deja
constancia en la bitácora y GET /api/health responde 503 cuando el último
latido tiene más de diez minutos. Las otras cuatro comprobaciones son la base,
las migraciones aplicadas, los avisos muertos y los reembolsos pendientes.
La respuesta no lleva secretos y se sirve sin caché, para poder apuntarle un
monitoreo externo. Sirve porque puede fallar: la primera vez que respondió
503 destapó tres defectos reales que ninguna prueba cubría (ver
docs/sprint-07.md).
robots.txt y sitemap.xml se generan desde la base: el mapa lista un producto
por idioma solo si tiene traducción, y robots bloquea /admin y /api.
Vive en /admin, fuera del árbol de idioma: es una herramienta interna, en
español, que no se indexa. El middleware de idioma lo excluye explícitamente —sin
esa exclusión el panel responde 307 hacia /es/admin, que no existe, y la
operación no puede entrar.
tel:— por un supuesto declarado:
recepción lo opera desde el celular. Un panel pensado para el celular funciona
en un escritorio; al revés no.Se generan al subir, no al leer: anchos de 400, 800, 1600 y 2400 px en AVIF
con respaldo en WebP, y la vitrina emite <picture> con srcset. El porqué está
en docs/decisiones/0001-entrega-de-imagenes.md:
se paga almacenamiento, que crece con el catálogo, en lugar de cómputo, que crece
con el tráfico justo cuando el negocio va bien.
Dos cosas que no son obvias:
public/ (en var/media por omisión, configurable con
MEDIA_DIR) y se sirven por src/app/media/[...path]/route.ts. Next resuelve
el contenido de public/ en tiempo de compilación, así que una foto escrita ahí
después del build responde 404 hasta el siguiente despliegue. En producción esa
ruta la reemplaza el CDN sobre el mismo almacén, sin cambiar las URL.bigint) con su moneda explícita.
Nunca decimales flotantes.date (una noche no es un instante); las salidas de tours son
timestamptz. La zona viene de locations.timezone, no del servidor:
Quintana Roo es America/Cancun (UTC−5, sin horario de verano).UPDATE de released_at, nunca un DELETE.