05 / 05Case study
Delicaté 4.0
E-commerce and brand site
Handmade soaps from the Dominican Republic, live on their own domain: a Django-managed catalog, an account-free cart that stays up to date and orders that close over WhatsApp.
- My role
- Requirements gathering, UX/UI, full-stack development and deployment
- Stack
- React 19 · Vite 8 · Django 5.2 · Django REST Framework · PostgreSQL · Docker · Railway
- Status
- Live on its own domain · commercial launch in validation
Scope
- 18automated tests, run in CI on every push
- 0accounts needed to buy
- 68 KBof JavaScript in production, gzipped
The challenge
The business needed to present its catalog with an identity of its own and receive well-formed orders, without taking on online payments or a logistics operation it doesn't have.
- What I built
- I redesigned and rebuilt the whole store — a Django REST catalog API, inventory management and a responsive React storefront — and took it to production on Railway with its own domain, continuous integration and 18 tests.
- Key technical decision
- I removed customer sign-up, the server-side cart and the checkout from the previous version, and turned the cart into a structured WhatsApp order: the same channel where the business already confirms, collects and delivers.
Design decisions

- Problem
- The price of a handmade soap is carried by the brand, not by a product list.
- Decision
- The home page presents the brand before the store, with a single main action.

- Problem
- Before buying, customers ask about benefits, skin type and ingredients.
- Decision
- The product view answers in a native dialog, without leaving the catalog.

- Problem
- A checkout that can't take payment is a broken promise.
- Decision
- The cart builds the order, makes clear it doesn't charge and closes it on WhatsApp.

- Problem
- A cart saved days ago can send old prices or quantities that are out of stock.
- Decision
- When you come back, the cart updates against the real catalog and says what changed.

- Problem
- On a touch screen there's no hover to reveal the buy button.
- Decision
- On touch, “Add” is always visible and the filters scroll in a single row.

- Problem
- If a sold-out soap disappears, whoever was looking for it can't tell if it'll be back.
- Decision
- It stays visible as “Sold out” with buying disabled, on the card and in the product view.

- Problem
- If the API fails, showing made-up products misleads the buyer.
- Decision
- Production shows an error with a retry; the demo catalog only exists in development.

- Problem
- The business had to change its catalog without touching code or rebuilding.
- Decision
- Price, stock, featured and visibility are edited right in the admin list.
System
Pick a module: its path lights up.Pick a module: its decision appears below.
Brand before store: a single main action leads to the collection.
React 19 · Custom CSS
Read in full from the API, following every page, and filtered in the browser; filters declare their state with aria-pressed.
fetch with AbortController · React 19
A native dialog answers benefit, skin, ingredients and stock without leaving the catalog.
Native <dialog> · React 19
No account. On return it's checked against the catalog: it takes the current price, caps each quantity at the stock and says what changed.
useReducer · Custom useCart hook
Built for the phone: on touch, “Add” is always visible and the cart takes the whole screen.
@media (hover: none) · Custom CSS
The form stores nothing on the server: it turns the question into a WhatsApp message.
React 19
Public and read-only: a paginated, filterable list and a product view by slug. The catalog is only written from the admin.
Django REST Framework 3.17 · Pages of up to 100
Price, stock, featured and visibility are edited in the list itself; photos are validated (JPG, PNG or WebP, up to 5 MB).
Django Admin · Pillow 12.3
CSP and the other headers on every response, one-year HSTS and rate limits that count the real IP behind the proxy.
Custom middleware · DRF throttling
The cart lives in the browser; the server never stores orders or buyer data.
localStorage
Four models, down from eight in 2024: products, messages, subscriptions and the team user.
PostgreSQL · psycopg 3.3 · dj-database-url 3.1
Uploaded photos live on a persistent volume; Django won't start if its path is relative.
Railway volume · 7-day cache
On every push: Django check, pending migrations, the 18 tests, lint and build.
GitHub Actions
A multi-stage image: Node builds the store and Python serves it, with DEBUG off by default.
node:24-alpine · python:3.13-slim
Migrates before release and only sends traffic to the new version if the health check, which queries the database, returns 200.
Railway · railway.json
A single process serves the store, API, admin and photos from the same origin: no CORS.
Gunicorn 23 · WhiteNoise 6.12
The order goes out as a pre-written wa.me message, on the channel where the business already confirms, collects and delivers.
wa.me link
When the link is shared, a 1200×630 image and the canonical URL come from the build configuration.
Open Graph · Twitter Card
Client
Home page
Brand before store: a single main action leads to the collection.
- Technologies
- React 19 · Custom CSS
- Sends to
- Catalog · Link preview
Technologies
43 tools across 10 areas. In the system, each module lists its own.
Languages
- Python 3.13
- JavaScript (JSX)
- CSS
- HTML
Frontend
- React 19.2
- Vite 8.2
- Rolldown 1.2
- @vitejs/plugin-react 6.0
Interface
- Custom CSS without a framework
- Lightning CSS 1.33
- System fonts
- 8 inline SVG icons
- Native <dialog>
Browser state
- useReducer and a custom hook
- localStorage
- fetch with AbortController
Backend
- Django 5.2 LTS
- Django REST Framework 3.17
- Django Admin
- django-cors-headers 4.9
- Pillow 12.3
- python-dotenv 1.2
Data
- PostgreSQL
- SQLite
- psycopg 3.3
- dj-database-url 3.1
Server and deployment
- Multi-stage Docker
- Gunicorn 23
- WhiteNoise 6.12
- Railway
- Persistent volume
- Custom domain with TLS
Integrations
- WhatsApp wa.me link
- Open Graph and Twitter Card
Quality and CI
- Django test runner
- DRF APITestCase
- ESLint 10.8
- eslint-plugin-react-hooks 7.1
- GitHub Actions
Tooling
- Node.js 24
- npm
- Git
- Railway CLI
Verifiable results
- Live at delicate.jonasjavier.dev as a single Docker container on Railway, with PostgreSQL and a volume for photos
- A catalog managed from Django, with no code changes or storefront rebuilds
- An account-free cart saved in the browser that, when you come back, updates to real prices and stock
- A complete order, with quantities and an estimated total, pre-written for WhatsApp; the site doesn't charge
- Migrations before release, a database-backed health check, CSP and rate limits
- 18 automated tests and a CI that checks migrations, lint and build on every push
Tour by module
30 screens across 7 modules.
Home and brand3 screens
Catalog and product6 screens

The catalog loads from Django and filters instantly.

Each card sums up price, skin type and weight before you open the product.

On desktop, “Add” appears when you hover over the photo.

The product view covers benefit, skin, ingredients and stock without leaving the catalog.

An out-of-stock product stays visible, but can't be added.

The product view says it's sold out and disables the purchase.
Cart and order3 screens
How ordering works3 screens
Catalog states2 screens
Admin2 screens
On the phone11 screens

The order goes out over WhatsApp from the same phone.

The notice explains the change before the order is sent.

On touch screens, “Add” is always visible and filters scroll sideways.

On the phone the product view stacks and scrolls inside the dialog.

The purchase action closes the product view at full width.

The headline and the main action sit within thumb's reach.

The menu closes with Escape or when you pick a destination.

The story reads top to bottom.

The three ordering steps, stacked.

The error is just as clear, and just as fixable, on the phone.

The empty state is handled on the phone too.
8 min read
The full case
Context
Delicaté is a brand of handmade soaps made in small batches in the Dominican Republic, and I built the site for it. It sells through conversation: the customer asks, chooses and arranges delivery over WhatsApp. The site had to present the brand and the catalog with an experience of its own and bring the customer into that conversation with the order already put together.
The repository keeps two generations. The 2024 one was a conventional store: sign-up and Google sign-in, a server-side cart, reviews, order history and a blog. Version 4.0, rebuilt in August 2026 and in production since September, starts by asking what the business actually needed.
The problem
A store with accounts, checkout, payments and inventory promises an operation this business doesn't have. Requiring sign-up to buy a bar of soap adds friction and gives nothing back, and a checkout that can't take payment is a broken promise. What it did need was to show the products well, help people choose and receive a clear order, because confirmation, payment and delivery would keep happening over WhatsApp.
My role
I gathered the requirements with the business, defined the information architecture and the user journey, designed the interface and developed the store, the API and the admin. I also deployed it on its domain and wrote the technical documentation, the launch checklist and the spec for a future line of custom soaps.
Users
The shopper browses the collection, filters by category, opens each soap's details and builds an order without creating an account. The business team maintains products, prices, stock, photos and featured items from the Django admin, and receives on WhatsApp a readable message with each product, its quantity and the total.
Constraints
- No payments, no logistics and no promise of real-time stock. That's why the cart total is "estimated".
- A catalog that changes without rebuilding anything. The store reads it from the API on every visit.
- Phone first. That's where the brand's audience shops, and where WhatsApp lives.
- No shopper data. The store keeps no profiles, addresses or orders.
How I built it
I started from the real sales journey — discover the brand, find a product, understand its benefits, build the order and open the conversation — and removed everything that didn't serve it. The rebuild dropped the previous version's models with explicit migrations (cart, cart lines, reviews and user profiles) and added a data migration to give every product a unique slug. It went from eight models to four, and from thirty runtime dependencies on the frontend to two: React and React DOM.
The backend is three Django apps: shop (catalog, API and admin), contact
(messages and subscriptions) and accounts (the team user, who signs in with
an email). The frontend is six React components, a cart hook and custom CSS,
with no state or component libraries. In September 2026 I got it ready for
production: I hardened Django, made the cart sync with the catalog and
deployed it on its domain.
Architecture decisions
- No customer accounts. The custom Django user exists only for the team; the shopper never signs up.
- A public, read-only API. The catalog is read through the API and written only from the admin. The store requests every page of the list, so a bigger catalog never leaves products out.
- A cart that doesn't lie. It lives in
localStorageand survives a reload. When the catalog arrives, each line takes the current price, its quantity is capped at the stock and discontinued products drop out of the cart; if something changed, the drawer says so. - A single container in production. Django serves the API, the admin, the photos and the React build from the same domain, with WhiteNoise: no CORS needed. In development, Vite proxies to Django.
- Demo data never replaces real data. The fallback catalog only shows up in development, or when explicitly enabled, with a visible notice. In production, if the API fails, the customer sees an error with a retry, not made-up products.
The WhatsApp order
"Finish on WhatsApp" opens a wa.me link with the order already written. There's
no WhatsApp API integration and nothing to pay per message: the server doesn't
record the order, and the conversation continues on the business's phone. This
is the message the cart in the screenshots generates, in Spanish, as the
business receives it:
¡Hola, Delicaté!
Quiero realizar este pedido:
• 2 × Avena Calma — RD$700
• 1 × Corazón de Lavanda — RD$425
• 1 × Café Despierto — RD$450
Total estimado: RD$1,575
Mi nombre es:
Prefiero: entrega / recoger
¿Me confirman disponibilidad y forma de entrega? Gracias.
The greeting used to carry an emoji that WhatsApp's web page turned into "�" when it redirected the link; now it goes without it. The contact form does the same with the name and the question: it doesn't store anything on the server either.
Production and security
- Ship only what works. Every push to
mainbuilds the image, applies migrations before release and only sends traffic to the new version if/api/health/, which queries the database, returns 200. If anything fails, the previous one stays up. - Headers on every response, including the ones WhiteNoise serves: a Content Security Policy, Permissions-Policy and X-Frame-Options; mandatory HTTPS, one-year HSTS and secure cookies.
- Rate limits that count the real IP behind the proxy: a thousand requests per hour per visitor, with tighter caps on the write endpoints.
- A safe startup. With
DEBUGoff, Django refuses to start with the development key or with a relative photo path. - Validated photos by format (JPG, PNG, WebP) and size (5 MB), served with a seven-day cache.
Challenges
The main one was building a convincing shopping experience without faking a payments or logistics infrastructure. The second was keeping the managed catalog, the filters, the product view and a cart that survives between visits consistent when prices or stock change. And the third was cutting down: going from an oversized store to a maintainable product of about 2,600 lines.
What deployment taught me
- Railway didn't apply
railway.jsonto the service created from the command line, and the first version went out without migrations. The configuration is now pinned on the service itself. - Git Bash turned the
/data/mediapath into a Windows path, and the photos were lost in a redeploy. I fixed it and added the check that now refuses to start with a relative path. - When the custom domain was added, Railway stopped exposing the
*.up.railway.appaddress; both hosts are now explicitly allowed.
Design and UX
The visual direction uses a warm palette of cream, forest green and terracotta, an editorial serif for headlines and plenty of breathing room to communicate craft, without downloading web fonts. The page tells a story in order: home, values, collection, brand story, how to order in three steps, FAQ and contact. When you add a product, the cart opens to confirm the gesture.
The states are all handled: loading, error with retry, empty category and
sold-out product. For accessibility, the product view is a native <dialog>,
the cart traps and returns focus, the mobile menu closes with Escape, there's a
skip-to-content link, filters declare their state with aria-pressed and
prefers-reduced-motion is respected.
What it doesn't have
So nobody assumes otherwise: there are no payments, no in-store search (only
the API accepts ?search=), no product variants, no more than one photo per
product and no dedicated URL for each soap: the product view is a dialog. There's
no order dashboard either, because orders never go through the server. The API
keeps two rate-limited endpoints for contact and newsletter that no store
screen uses today.
About the screenshots
The screenshots were taken on September 25, 2026, on the store's production build, served locally with a throwaway database and the catalog's ten products. Some states were triggered on purpose to show them: the sold-out product, the catalog that's slow or fails, the empty category and the saved cart with old prices. What you see in each one is production code. The product photos are the original project's; the lifestyle images on the home page and in the story were generated for this version. Screens with any visible defect were left out.
What I learned
A good product doesn't need to fake more infrastructure than the business can run. Removing accounts, the server-side cart and checkout made the system smaller, safer and easier to adopt, and turned a real limitation into a coherent flow. The version that delivers the most wasn't the one that did the most, but the one that best fit how the business sells.
Current status
Live at delicate.jonasjavier.dev since September 25, 2026: a Docker service on Railway with PostgreSQL, a volume for photos and its own domain over HTTPS. The repository is public for evaluation, under a proprietary license, with continuous integration green and 18 automated tests passing. Before announcing it, the checks that depend on the business are still on its launch list: delivery and privacy policies, a full order tested on Android and iPhone, backups and monitoring. I don't publish sales or traffic because I don't measure them.
The product's next line is already specified: a guided custom-soap configurator whose result is a WhatsApp request, not an automatic purchase. It isn't part of the system yet.








