Lucas Rezende
Back to all work

Case 03 · Retail · 2024–2026

Loja Ella

End-to-end platform for a plus-size consignment boutique — operations ERP, editorial storefront and in-house logistics, evolving in production for two years.

● Live
Loja Ella
stack versions in 6 years
6
active clients
+1,500
integrated modules
16

Context

Where
the problem lived.

01 · Context

The setup

System alive for 6 years, with 6 stack versions along the way. Started in 2020 with Excel (basic consignment control), moved to VBA in 2021 (records + billing), tried MS Access (v3), evolved to pure React (v4), then Next.js (v5), and now Next.js + Supabase + Realtime (current). Each jump happened when the tool's ceiling hit the operation's real problem.

02 · Problem

The real pain

Brazil's plus-size fashion market moves billions but is ignored by major retail ERPs. A plus-size consignment boutique adds layers no standard system handles: each piece has its own lifecycle (goes to the customer's home, comes back, sells, re-consigns), editorial curation, deeply personal service, in-house courier delivery. Operations ran on spreadsheets, WhatsApp and paper — scale only multiplies operational failure.

03 · Response

How I responded

Custom operating system with 16 integrated modules, evolving every week. Covers piece-level consignment tracking, courier logistics, automatic client↔collection matching, editorial storefront integrated with the same inventory, and a realtime Kanban. Replaced spreadsheets, scattered WhatsApp threads and paper. It's the only 'ERP' the store has ever had.

Stack

Every piece
with a reason.

No fashion picks. Each dependency here passed through a filter of maintenance cost, time-to-market and fit with a small team.

  • 01React + Vite

    Internal app (dashboard) is a SPA with desktop-software feel. No SSR needed — needs responsiveness and keyboard shortcuts

  • 02Next.js (public storefront)

    Editorial storefront uses SSG/ISR for organic SEO and shareability. Reads from the same Postgres as the dashboard

  • 03Supabase

    Auth + Postgres + Realtime on the Kanban + Storage for piece photos. Multi-role (owner, attendant, courier, finance)

  • 04TypeScript

    16 modules with a rich schema — without typing, silent regressions would be weekly. tsc in CI blocks bad merges

  • 05Tailwind + shadcn/ui

    Iteration speed — owner asks for a sales flow tweak, ships the same day without touching the design system

  • 06Vercel

    Auto-deploy on merge, branch previews. Owner tests features before they go live, zero release anxiety

  • 07WhatsApp Business API

    Per-customer deep links, templated messages by status (bag ready, return pending, piece reserved)

Architecture

How it all
talks.

Internal dashboard and public storefront consume the same Postgres — inventory is the single source of truth. When a piece changes status anywhere, Supabase Realtime propagates the change to the Kanban and the inventory-health materialized view. Couriers have a simplified PWA showing their daily route. WhatsApp is an outbound channel, never a source of truth.

01 · client

Internal dashboard

SPA · store team

client

Public storefront

Next.js · SSG/ISR

client

Courier app

PWA · daily route

client

02 · edge

Realtime

Kanban · inventory health

edge

03 · data

Postgres

Inventory · clients · consigned · sales

data

Storage

Piece photos

data

WhatsApp

Customer notifications

external

Main flows

  • dashboardper-role RLSdb
  • vitrineread-onlydb
  • motoboyroute + deliverydb
  • dbstreamrealtime
  • realtimepushdashboard
  • dashboarddeeplinkwpp

Technical decisions

Seven choices
with explicit tradeoff.

Each one carries the problem, the options considered, the choice I made and the price I paid for it.

Decision 01

Piece-level granularity, not bag-level

Problem

Traditional consignment systems track 'bag X went to customer Y'. Not enough — each piece has its own lifecycle (goes back to inventory, enters another bag, sells). The bag is just a temporary grouping. A customer returns 4 of 8 pieces, and those 4 returns need to go to the next customer with zero confusion.

Options considered

  • aBag-level tracking — simple, but loses status precision and history
  • bPer-piece tracking with history — complex, but reflects real operations
  • cHybrid: bag as grouping, piece as primary entity

Choice

Each piece has its own status (in_stock, in_consignment, returned, sold, re_consigned). A bag is a view of N pieces currently in consignment for one customer. Status changes emit events that fire WhatsApp (customer) or alerts on the Kanban (team).

Tradeoff accepted

More complex schema, more joins, more migrations. In exchange: we answer questions like 'how many times did this blouse return before selling?' — impossible with bag-level tracking. And the system stays coherent when consigned scales to customers with 12 bags in history.

Decision 02

Client↔collection matching as a pure SQL function

Problem

A new collection arrives — 30 pieces. Out of 1,500+ active customers, who will like it? Blasting photos to everyone is spam. Only messaging top buyers is short-sighted (misses new customers, misses those who switched sizes). The question: how to prioritize 20 contacts that cover 80% of interest?

Options considered

  • aManual tags — attendant decides who fits (doesn't scale past 500 customers)
  • bClassical ML (clustering, collaborative filtering) — overkill for current base + black box
  • cComposite score via SQL function: tag overlap + size compatibility + purchase recency

Choice

Each piece has tags (color, print, occasion, fit). Each customer has a tagged profile (size, preferences, history). A SQL function exposed via Supabase RPC computes a score per customer and returns the top 20 ordered, with 'reasons' (why this customer). The 'Match' button in the panel runs in ~80ms.

Tradeoff accepted

Score depends on tag quality — if the attendant forgets to tag, the score gets shallow. In exchange: zero infrastructure cost, full explainability ('why this customer?'), and the owner understands and tweaks the logic directly. SQL is the universal data interface — always auditable.

Decision 03

Inventory health as a materialized view

Problem

Stagnant inventory is silent loss — capital tied up in pieces that don't move. But 'reviewing piece by piece' is unfeasible with 1,244 active items. The owner needs an actionable signal: 'prioritize selling these 20 today, discount these 50, retire these 10'.

Options considered

  • aList the oldest pieces — simple, ignores price and category popularity
  • bCompute score on each dashboard load — works up to ~300 pieces, breaks at scale
  • cMaterialized view with scheduled refresh + composite score

Choice

Materialized view recomputes daily at 4 AM: days in stock × cost × abandoned tags + flag for dead collection. Top 20 pieces appear on the panel with suggested action (Match to customer, graduated discount, retire). The dashboard pulls the materialized result in <50ms.

Tradeoff accepted

Materialized view requires scheduled refresh (not instant). In exchange: fast dashboard, actionable metric, and the system literally tells the owner what to sell today. She opens the app in the morning with immediate clarity on priority.

Decision 04

Editorial storefront integrated with the same database

Problem

A separate catalog in Shopify or Loja Integrada duplicates inventory. Updating in two places breaks sync: a sold piece still appears on the storefront, a customer reaches out excited, the team handles the awkward correction.

Options considered

  • aSeparate catalog with daily sync — fragile, always behind
  • bWebhook syncing two databases — medium latency, another failure point
  • cSame database, storefront as a filtered view (status = active + tag 'storefront')

Choice

The public site reads directly from the same products table as the dashboard, filtered by status. A piece that leaves inventory disappears from the storefront on the next SSR (~5 min via ISR). Editing in the panel impacts the site immediately, no deploy, no webhook.

Tradeoff accepted

The storefront is coupled to the internal schema — a major model change affects both. In exchange: zero risk of desync (a sold piece never appears online), zero cost of external e-commerce platform, zero extra training for the team.

Decision 05

Dashboard with goal projection + actionable alerts

Problem

The owner wants to know 'am I hitting this month's goal?'. Looking at a spreadsheet doesn't answer that. Raw metrics like 'sold R$ X' without comparison are data, not decisions. Worse: an alert that doesn't trigger action is just noise.

Options considered

  • aGeneric dashboard with a sales chart — informative, not actionable
  • bExternal BI (Metabase, Looker) — overkill, another login, recurring cost
  • cCustom dashboard with Ideal Goal vs Real + prioritized 'urgent' cards with direct action

Choice

Daily panel shows: Ideal Goal × Real Sales chart (line over area), end-of-month projection based on current pace, and top 3 'urgent' cards with a direct-action button. Example: 'Collection 127 arrived with 28 pieces' → button 'Open Match'. 'Inventory slowing' → 'Suggest discount'.

Tradeoff accepted

Each urgency card requires its own detection code (hard-coded rules based on DB metrics). In exchange: the owner opens the app in the morning and already knows what to do — no need to interpret a chart or rebuild priority from scratch.

Decision 06

Realtime + RBAC as product pillars

Problem

Operations happen in parallel: owner selling at the counter, attendant answering WhatsApp, courier delivering, finance closing the day. Without realtime, everyone works on a stale snapshot — a piece sold at the counter still appears available to the attendant who wastes time offering it.

Options considered

  • a30-second polling — simple, but loads the server and delays propagation
  • bManual page refresh — breaks the workflow
  • cSupabase Realtime + per-role RLS to propagate only what each user can see

Choice

Kanban and inventory health connected to Supabase Realtime via subscriptions. When a piece changes status, all connected dashboards receive the update in <500ms. RLS applied to the subscription: the courier only receives events from their deliveries, finance only from their entries.

Tradeoff accepted

An active subscription consumes a connection per user (operational impact at very large scale). In exchange: the entire team works in sync, decisions happen in realtime, and the system delivers the feel of a professional product — not a decorated spreadsheet.

Highlighted implementation

One slice
that tells the story.

supabase/functions/match-clients.sql
sql
-- Client↔collection match: composite score by tag overlap + size + recency
create or replace function match_clients_for_collection(p_collection_id uuid)
returns table (
  client_id uuid,
  client_name text,
  score numeric,
  reasons text[]
)
language sql stable as $$
  select
    c.id as client_id,
    c.full_name as client_name,
    (
      coalesce(tag_overlap.count, 0) * 3.0 +
      case when c.size = any(coll.sizes) then 5 else 0 end +
      case
        when last_purchase.purchased_at > now() - interval '60 days' then 4
        when last_purchase.purchased_at > now() - interval '180 days' then 2
        else 0
      end
    ) as score,
    array_remove(array[
      case when tag_overlap.count > 0
           then format('shared tags: %s', array_to_string(tag_overlap.tags, ', ')) end,
      case when c.size = any(coll.sizes) then 'compatible size' end,
      case when last_purchase.purchased_at > now() - interval '60 days' then 'recent buyer' end
    ], null) as reasons
  from clients c
  cross join collections coll
  left join lateral (
    select count(*) as count, array_agg(tag) as tags
    from unnest(c.preferred_tags) tag
    where tag = any(coll.tags)
  ) tag_overlap on true
  left join lateral (
    select max(purchased_at) as purchased_at
    from sales s where s.client_id = c.id
  ) last_purchase on true
  where coll.id = p_collection_id
  order by score desc
  limit 20;
$$;
SQL function returns the top 20 clients for a new collection, with explainable reasons. Called via Supabase RPC, returns in ~80ms over a 1,500-client base.

Technical metrics

Numbers
that matter.

  • Integrated modules

    16

    from consignment to courier

  • Active clients

    +1,500

    with tagged profiles for matching

  • Active pieces in inventory

    1,244

  • Years in continuous production

    2+

    weekly feature evolution

  • Access roles

    4

    owner, attendant, courier, finance

  • Match → top 20 clients

    ~80ms

    SQL function via Supabase RPC

  • Realtime latency (Kanban)

    <500ms

    Supabase Realtime + per-role RLS

Retrospective

What I'd do
differently today.

Decisions I'd reverse knowing what I know now. Not regret — the kind of learning that changes the next project.

  1. 01

    Client tags as a separate entity from day one

    Tags live in a customer record array. Works for 1,500 clients, but a schema change (rename a tag, group 'printed' and 'pattern') becomes an array migration. A normalized client_tags table would have been the right call for scaling analytics and cross-tabulation. It's a planned refactor for next quarter.

  2. 02

    Per-piece status history in its own table

    Each piece holds current and previous status — functional for the flow. For analysis like 'average time in consignment before sale' I had to reconstruct from logs. A piece_status_history table with timestamp per transition would have saved weeks of analysis and enabled trivial historical dashboards.

  3. 03

    Domain events instead of direct updates

    Every important change (piece sold, bag returned) is a direct update on the piece. Works, but integrating WhatsApp/notifications stays coupled to the caller. An outbox pattern with events would have decoupled this long before the system grew. The retrofit cost today is higher than it would have been from the start.

  4. 04

    Tests from version 1

    16 modules grew organically, with uneven test coverage. Today every deploy carries anxiety — silent regression in an old module is a real risk. I should have been running Vitest from week one, even with few tests — testing discipline is cultural, not retroactive.

Gallery

In use.

07 frames

Loja Ella — Case study · Lucas Rezende