Product catalog and admin panel for an electrical shop
No cart, no checkout, one WhatsApp button.
This is the website for an electrical and security-equipment shop in Çanakkale: switches, cable, cameras, intercoms, satellite gear. The first decision made everything simpler: it isn't a webshop. It's a live price-and-stock catalog, and every product page has a button that opens WhatsApp with the message already written. Customers still talk to a person, and the site makes sure they ask about the right thing.
export function waLink(number: string, message: string) {
const digits = (number || "").replace(/[^\d]/g, "");
return `https://wa.me/${digits}?text=${encodeURIComponent(message)}`;
}The stack is TanStack Start (React 19), Tailwind v4 and Zod, deployed as a Cloudflare Worker with D1 (SQLite) for the data and R2 for the photos. There's a custom admin panel, so the owner never has to ask me to change a price.
Counting without inflating
The dashboard shows the most viewed products and the ones people ask about on WhatsApp. Counters like that are easy to inflate, so a small table has the primary key (product, kind, visitor fingerprint, day). Recording a stat just tries to insert that row, and if the insert fails, this visitor already counted today and nothing else happens. The fingerprint is a truncated SHA-256 of IP and user agent, so no raw IP address ever reaches the database.
// primary key = (product, kind, fingerprint, day)
try {
await db.run(
"INSERT INTO product_stat_hits (product_id, kind, fingerprint) VALUES (?, ?, ?)",
[data.productId, data.kind, await clientFingerprint()],
);
} catch {
return { counted: false }; // already counted today
}
await db.run(`UPDATE products SET ${column} = ${column} + 1 WHERE id = ?`, [data.productId]);Discounts as an operation
The discount tab applies a percentage (1 to 99) to everything, one category or one brand. The new price is computed from the list price, rounded to kuruş and stored beside it, and the panel keeps a note of what it applied so it can be undone in one click.
const value = clear ? null : Math.round(p.price * (1 - pct / 100) * 100) / 100;The storefront only shows a discount when the discounted price is really lower than the list price, so a bad value can't show up as a sale. And price_updated_at moves only when a price field changes, not on every edit, so the date on a product card tells a shopper how fresh the price is.
export function hasDiscount(p: Pick<Product, "price" | "discount_price">) {
return p.discount_price != null && p.discount_price > 0 && p.discount_price < p.price;
}Photos that clean up after themselves
Uploads are admin-only, capped at 5 MB, and limited to an allowlist of image types. SVG is deliberately not on it: an SVG can carry a script, and opening the file's URL directly would run it, which is stored XSS. Files are also served with nosniff.
export const MIME: Record<string, string> = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif", ".ico": "image/x-icon",
// no ".svg": it can embed <script>
};Everything lives in one R2 bucket, laid out so a file's key says what it is:
| key | what lives there |
|---|---|
| products/<slug>/<slug>.jpg, <slug>_2.jpg | product photos, numbered from what's already in the folder |
| uploads/header-footer/logo-header.<ext> | fixed slot, a new upload replaces it |
| uploads/main-page/hero-1.<ext> | fixed slot, same rule |
| uploads/site-favicon.<ext> | fixed slot, same rule |
| uploads/<uuid>.<ext> | one-off images such as brand logos |
New product photos are numbered from what's already in the folder, so an old file is never overwritten, even if one was deleted in between.
function nextProductFilename(existing: string[], slug: string, ext: string): string {
const suffixPattern = new RegExp(`^${slug}_(\\d+)$`);
let maxIndex = 0;
for (const name of existing) {
const base = name.replace(/\.[^./]+$/, "");
if (base === slug) maxIndex = Math.max(maxIndex, 1);
else {
const match = base.match(suffixPattern);
if (match) maxIndex = Math.max(maxIndex, Number(match[1]));
}
}
const nextIndex = maxIndex + 1;
return nextIndex === 1 ? `${slug}${ext}` : `${slug}_${nextIndex}${ext}`;
}Removing a photo or deleting a product deletes the objects, so nothing is left orphaned. Fixed slots replace their previous file, and if the new extension differs, the stale one is removed too. The one exception is the header and footer logos: they can fall back to a shared file, so they are never deleted on clear.
Login without an auth service
Admin auth is one small file. Passwords are hashed with PBKDF2-SHA256 through Web Crypto, which Workers support natively, and sessions are a sealed cookie that expires after seven days. The iteration count is stored inside the hash string, so it can be raised later without breaking old passwords.
const hash = await pbkdf2(password, salt, ITERATIONS);
return `${ITERATIONS}:${toHex(salt)}:${toHex(hash)}`; // iterations travel with the hashThere's no sign-up: admins are created by other admins, nobody can revoke their own access, and the very first admin comes from environment variables, only when the users table is empty. Secrets live in Wrangler secrets, not in the config. Every server function sits behind CSRF middleware, and the admin route is marked noindex.
Cloudflare Workers pitfalls
Three things cost me time. First, route files ship their top-level imports to the browser, so importing the database module at the top of a route pulls cloudflare:workers into the client bundle and breaks it. Every server function imports what it needs inside its handler.
export const someServerFn = createServerFn({ method: "POST" }).handler(async () => {
const { getDb } = await import("@/lib/db.server"); // inside the handler, never at the top of the file
const db = await getDb();
});Second, D1's exec() splits statements on newlines, which breaks multi-line CREATE TABLE, so the schema is applied one statement at a time with prepare().run().
Third, when a server function throws, the framework swallows it into a generic 500 JSON body with no stack trace. A small wrapper around console.error remembers the real error, and the server entry recognizes the swallowed body, logs the full cause chain and serves a friendly HTML error page instead of raw JSON.
function isH3SwallowedErrorBody(body: string): boolean {
const payload = JSON.parse(body) as { unhandled?: unknown; message?: unknown };
return payload.unhandled === true && payload.message === "HTTPError";
}Smaller details
Filters and sorting live in the URL (category, brand, price range, in stock, discounted, sort) and are validated by the router, so any filtered view is a link you can send someone on WhatsApp. Search lowercases with the Turkish locale so İ and I behave, and slugs transliterate ç ğ ı ö ş ü. The sitemap is generated from the active products, and there's a security.txt.
A shop site is really two products: the one customers see, and the one the owner uses every day. The second one deserves as much design as the first.