Case Study

Fragscentric

1 shared GLB model ~$0 infrastructure/month

I’m a perfume collector. In 2022 I decided to share my passion with others and started selling decants. The motto was simple: make the luxury affordable for others, as well as for me, so I can collect more.

I shared reviews on social channels and was receiving orders through Telegram DMs. So I became irregular at managing this. Because I’ve been their choice for personalized selling. My recommendations and so on.

I was getting plenty of questions, often the same ones. Seasonal recommendations, stock availability, pricing queries, restock requests.

So I decided to build a site to cover all these. First I tried to create a WooCommerce site. But the experience I wanted to give my customers, WordPress was not the best option in terms of price and experience. So I made a native application. I picked Astro + Hono and Cloudflare because it’s affordable. That much which feels almost free.

Firstly, this was not traditional e-commerce. So I wanted to ensure a hybrid experience. My journey and my business. Both on the same place.


The Bottle Problem

As I’m always picky about the details, so I was suffering with the product showcase. My labeling is two-sided. When I show the front side, it looks cropped. When I show the left side, it feels like logo only. When I bring it half-half, the bottle looks triangular. Which confused my customers. So finally I decided to render 3D bottles.

When it comes to 3D bottles, speed and experience was the crucial thing. Because it’s not a site where people land to experiment. They come with real purpose.

I prepared the 3D model of my bottle with Blender. Then embedded and managed lighting and shading on the web with Three.js.


One Model, All Products

But then the challenge arose: if I prepare a separate 3D model for each product, it makes the site almost unusable. Dozens of different GLB files? That’s not happening. So I tried to manage the textures and labels dynamically. Site-wide, it’s actually a single model. But it looks like a separate 3D for each product page.

Here’s how I solved it.

Each product has its own label image, a flat PNG. And the bottle model has a second UV channel specifically mapped for the label area. So when a user lands on a product page, Three.js loads that product’s label texture and swaps it onto the material at runtime. One .glb file. Every label different. The bottle shape stays the same because it IS the same. Only the skin changes.

The trick was getting the UV mapping right in Blender. The primary UV channel handles the bottle geometry, glass, cap, all that. But the label mesh gets a second UV channel with perfect 0-to-1 bounds. So the texture always maps cleanly. No stretching. No tiling. No weird artifacts.


Glass, Light, and Studio Feel

Then came the glass problem.

Real glass is tricky on the web. You need light to pass through it. You need refraction. You need reflections. But you also need it to run at 60fps on someone’s phone. So I used MeshPhysicalMaterial, Three.js’s most realistic material, and dialed in the properties carefully. Transmission at 0.92 for that see-through quality. Roughness at 0.015 so it’s nearly mirror-smooth. A slight warm tint on the edges. And a clearcoat layer on top for that glossy, just-polished look.

For lighting, I didn’t go with the typical HDR environment map approach. Instead I built a five-light setup that mimics actual studio product photography. A key light from the left-front. A fill from the right. A top light for the cap. A warm bounce from below. And ambient everywhere to prevent harsh shadows. All warm tones, golden, not clinical white. Because this is a luxury product, not a product listing.


The Liquid Inside

The liquid inside the bottle is not part of the 3D model. It’s generated procedurally at runtime. I create a rounded box geometry that fills most of the bottle volume, then use a clipping plane to cut it off at the fill level. And on top of that, a curved surface, a meniscus, to simulate that concave dip you see in real liquid. The liquid even bobs up and down with the bottle. Subtle, but it makes the whole thing feel alive.

The bottle gently floats. Sinusoidal motion, just 0.018 units up and down. It auto-rotates slowly, and pauses when the user grabs it to inspect. The shadow underneath pulses in sync. It’s these small things that make it feel real instead of rendered.


Performance as a Non-Negotiable

Now, performance. This was the non-negotiable. I’m selling perfume, not demoing WebGL. People come to buy, not to wait. So:

  • Draco compression on the GLB file. Keeps it small.
  • Pixel ratio capped at 2. No need to render 4x on a Retina display.
  • Fake shadow instead of real shadow maps. A canvas-drawn gradient on a plane. Looks 90% as good, costs 10% of the render.
  • The entire 3D viewer is a React island inside an otherwise static Astro page. So the rest of the page loads instantly. The 3D loads only where it’s needed.

One Source of Truth

The same single GLB and label textures that power the live viewer also generate every product image on the site. No manual Photoshop, no per-product photography.

A headless-Chrome pipeline loads each bottle with its label into the same Three.js setup and renders it to a WebP on white. Sharp tight-crops the bottle into a card thumbnail. Another pass composites three cards into a collection hero. It all runs in the build, so npm run build:full regenerates the catalog imagery from the same source the viewer uses.

Change a label, and the 3D bottle, the product card, and the collection hero all update together. Because they’re all the same bottle.


Serverless Backend

For the backend, I needed something equally lean. Orders, stock management, admin panel, but without paying for a server. Cloudflare D1 gave me SQLite at the edge. Hono handles the API routes as Cloudflare Pages Functions. The whole backend is serverless. No always-on server, no monthly VPS bill. When someone places an order, it hits /api/orders, writes to D1, done. The admin panel is just static pages that talk to the API with JWT auth.

The total infrastructure cost is effectively zero. Cloudflare Pages free tier. D1 free tier. Static assets on CDN. Serverless functions only run when called. For a business that started as Telegram DMs, that matters.


An Assistant, Not a Chatbot

Earlier I mentioned the questions that wouldn’t stop. Seasonal recommendations, stock checks, pricing, restock. Those ate my time. And not every customer wants to DM the owner just to ask if something is in stock. They want an answer, fast, and they want it at 2am.

So I added a fragrance advisor. A small chat widget in the corner of the site. “Ask Fragscentric.” It knows the catalog — every product, every note, every season, every price, every size’s live stock level. Ask it “something fresh for summer under ৳2000” and it gives you real options from the shelves, not guesses. Tap a recommendation and you’re on the product page.

The model underneath is GLM-5.2, called through an Anthropic-compatible API from the same Hono backend. But the model doesn’t pick products on its own. I pass it the actual catalog — filtered by what the customer seems to be asking for — and ask it to choose and explain in plain language. That keeps recommendations grounded in real stock, real prices, real seasons. No hallucinated bottles. No discontinued scents.

Quick replies get people started — “Summer picks,” “Office friendly,” “Gift for her,” “Track my order.” A seasonal chip updates with the month. The conversation history persists in the browser, so coming back doesn’t reset the thread.

And it knows when to step aside. If a customer wants to talk to a human, there’s a “Talk to a human” chip that hands them back to me. The advisor handles the easy eighty percent. I handle the twenty that actually needs judgment.

That’s what I mean by truly an assistant. Not a chatbot that talks. One that knows the store, knows the season, knows what’s on the shelf, and quietly does the work I used to do by hand.


Back to Telegram, But Different

The orders used to come through my Telegram DMs. The site moved that to a proper storefront. But once orders started flowing, I realized I was still opening the admin panel constantly. New order? Switch tabs. Restock request? Switch tabs. Status update? Switch tabs.

So I went back to Telegram — but this time as the store owner’s tool, not the customer’s channel.

I built @fragscentric_bot. It runs on the same Cloudflare Pages Functions as the API. No new server. No extra packages — just fetch() calls to the Telegram Bot API.

Every new order pings my phone. Customer name, items, total, payment method, address. Inline buttons right under the message — Confirm, Cancel, Shipped, Delivered. I can move an order through its whole lifecycle without leaving the chat. The buttons only show valid transitions, so I can’t accidentally ship something that was never confirmed.

Restock requests, out-of-stock alerts, bulk restocks — all push notifications. /orders pending gives me the last five pending orders. /stats shows today’s revenue by status. /note FS-... Customer wants Friday delivery appends a timestamped line to the order. /stock sauvage tells me what’s left of a fragrance.

The webhook URL carries a path secret derived from the bot token, and Telegram signs every inbound update with a separate secret header. The bot is private by configuration — one admin ID, mine. Anyone else gets Unauthorized. No first-user binding flow, no public commands.

There’s a nice symmetry to it. The business started in Telegram. Then it outgrew Telegram. And then Telegram came back — not as the storefront, but as the cockpit. The site handles the customers. The bot handles me.


A Real Storefront

The original site was a showcase with a chat. It’s a full store now.

Checkout re-prices every line from the database on the server, not from whatever the cart sent. Stock is reserved atomically — a conditional UPDATE that only fires if stock >= qty. No overselling, no race between two customers grabbing the last decant. Each order line snapshots its price, so the record stays honest even if prices change later.

Customers can create accounts. Wishlist, order history, a saved delivery address. Guest orders get a signed tracking link, and a registered customer can claim a past guest order to attach it to their account. No forced sign-up — just a softer path for people who want to come back.

There’s also Signature Blends. Mixed perfumes sold under Fragscentric names, where the component fragrances stay admin-only. Customers see the story, the notes, the price. Never the recipe. Pricing is proportional to how much of each component goes in, calculated behind the scenes.


Built for Agents, Not Just Browsers

Most store owners think about Google. I wanted Fragscentric to work for the AI agents and shopping assistants that answer customer questions too.

So there’s an llms.txt at the root — a plain-English guide that tells agents what the site is, where the catalog lives, what the policies are, and which areas are private. Every page carries JSON-LD for products, breadcrumbs, and articles, so the structure is machine-readable. I deliberately left out fabricated star ratings and reviews. The data is real, or it isn’t there.

Analytics is first-party and consent-gated — no Google Analytics, no third-party scripts. Events land in D1, only after a visitor accepts. The funnel runs page-view to purchase, campaign ?ref= codes attribute revenue, and I see it all in my own admin. It’s my store’s data, in my own database.

On the operational side: an order confirmation goes out by SMS with a signed tracking link. Pathao courier status is pulled live and rendered inline on the tracking page — customers don’t bounce to a courier site. And the whole thing ships through CI: secret scanning, the full test suite, and end-to-end Playwright runs on every PR, with a preview deploy per branch. Nothing reaches production untested.


The Site Today

Each fragrance has full notes, top, middle, base, seasonal tags, time-of-day recommendations. Customers can filter by season, by occasion, and price range. The stock updates in real-time from the database. Restock requests are built in. Signed-in customers get a wishlist, order history, and live courier tracking. The questions I used to answer manually now the site handles them.

I built it because I was trying to share a personalized perfume experience, not a marketplace. A story, not a storefront.

Constraints are always a feature. I couldn’t afford a server, so I went serverless. I couldn’t model dozens of bottles, so I made one model work for all. I couldn’t use heavy shadow maps, so I faked them. Every limitation pushed me toward a simpler, faster solution. And the result is something that loads fast, looks premium, and costs almost nothing to run.

That’s Fragscentric. Not built to impress developers. Built to share my passion beautifully. And if you want to fuel up my journey you can.