Stefan Petanovski — Personal Site & Merch Store

11/4/2025

Stefan Petanovski — Personal Site & Merch Store

Overview

We built a bespoke site for guitarist Stefan Petanovski that blends a fast storefront (physical CDs and merch) with a flexible CMS for pages, albums, and services. A dedicated admin app manages catalog, content, SEO, and orders end‑to‑end.

Homepage

Architecture

  • Monorepo apps: web/ (storefront), admin/, and server/ (API)
  • API: Node.js/Express + MongoDB (Mongoose)
  • Payments: Stripe Payment Intents + webhook order finalization + receipt email
  • Media: image uploads via server with Sharp processing
[web]    → React + Vite + Tailwind, react-helmet-async for SEO
[admin]  → React + Vite admin with catalog/content/SEO tools
[server] → Express API (auth, catalog, cart, checkout, pages, sitemaps, uploads)
           models: Product, Order, Album, Collection, Page, Service, ShippingZone, Coupon, User

About page

Features

  • Storefront: albums and merch with variants (size/color/format), stock control, cart/checkout
  • CMS: create/edit pages, service pages, about, testimonials, home hero
  • Shipping: zones and methods; rates included in order totals
  • Coupons: campaign codes and per‑order capture
  • Orders: status timeline, email receipts, Stripe webhook sync

Product & album

Catalog & Albums

  • Products: variant attributes (size/color/format), per‑variant SKU, stock, featured image
  • Collections: group releases or merch capsules for curated browsing
  • Albums: dedicated detail pages with track lists and artwork; merch items can cross‑link

Advanced Implementation

Server‑side cart validation and Payment Intent

// apps/server/src/routes/checkout.js (excerpt)
async function validateCart(lineItems) {
  const results = [];
  for (const item of lineItems) {
    const product = await Product.findById(item.productId).lean();
    if (!product) throw Object.assign(new Error('Product not found'), { status: 400 });
    const variant = product.variants[item.variantIndex];
    if (!variant) throw Object.assign(new Error('Variant not found'), { status: 400 });
    if (variant.stock < item.qty) throw Object.assign(new Error('Insufficient stock'), { status: 400 });
    const unitPrice = variant.salePrice || variant.price;
    const attrs = variant.attributes || {};
    const parts = [];
    if (attrs.size) parts.push(attrs.size);
    if (attrs.color) parts.push(attrs.color);
    if (attrs.format) parts.push(attrs.format);
    const variantLabel = parts.join(' / ');
    results.push({ title: product.title, sku: variant.sku, unitPrice, total: unitPrice * item.qty, variantLabel });
  }
  return results;
}

Admin — Dashboard

// apps/server/src/routes/checkout.js (excerpt)
router.post('/payment-intent', authenticate, async (req, res, next) => {
  const { email, currency = 'usd', lineItems, shipping } = req.body;
  const validated = await validateCart(lineItems);
  const subtotal = validated.reduce((s, li) => s + li.total, 0);
  const shippingAmount = Math.round((shipping && shipping.rate) ? shipping.rate * 100 : 0);
  const amount = Math.round(subtotal * 100) + shippingAmount;
  const pi = await stripe.paymentIntents.create({ amount, currency, receipt_email: email, automatic_payment_methods: { enabled: true } });
  const order = await Order.create({ number: `ORD-${Date.now()}`, email, lineItems: lineItems.map((it, i) => ({ ...it, ...validated[i] })), totals: { subtotal, shipping: shipping?.rate || 0, grandTotal: subtotal + (shipping?.rate || 0), currency }, status: 'pending', stripePaymentIntentId: pi.id });
  res.json({ clientSecret: pi.client_secret, orderId: order._id });
});

Stripe webhook → order finalization + email receipt

// apps/server/src/routes/checkout.js (excerpt)
if (event.type === 'payment_intent.succeeded') {
  const pi = event.data.object;
  const order = await Order.findOne({ stripePaymentIntentId: pi.id });
  if (order) {
    order.status = 'paid';
    order.timeline.push({ status: 'paid', note: 'Stripe webhook' });
    await order.save();
    // nodemailer: themed receipt with line items and totals
  }
}

SEO & Branding

  • SEO: storefront uses react-helmet-async (titles, descriptions, canonicals, OG/Twitter); shared builders output JSON‑LD for products/albums/breadcrumbs; API exposes sitemaps
  • Brand: dark, high‑contrast aesthetic aligned with metal; cyan glow accents on key surfaces and email headers
  • Admin SEO tools: per‑page/per‑product fields for title/description/social image
// apps/web/src/components/SEO.jsx (excerpt)
<Helmet>
  <title>{title}</title>
  <link rel="canonical" href={url} />
  {tags.map(t => t.name ? <meta name={t.name} content={t.content} /> : <meta property={t.property} content={t.content} />)}
  {jsonld && (Array.isArray(jsonld) ? jsonld : [jsonld]).map((obj, i) => (
    <script key={i} type="application/ld+json">{JSON.stringify(obj)}</script>
  ))}
</Helmet>
// shared/lib/index.js (excerpt)
export function productJsonLd({ name, description, images = [], offers = [] }) {
  return { '@context':'https://schema.org', '@type':'Product', name, description, image: images, brand:{ '@type':'Brand', name:'Stefan Petanovski' }, offers }
}

Admin — Content Manager

Performance & Ops

  • API hardening: Helmet, CORS, rate limiting; structured logging with Pino
  • Media: Sharp for image processing; uploads stored locally and organized by date
  • Data model: variants on products (attributes: size/color/format), stock per variant

Admin Workflows

  • Pages: create/update publishable CMS pages (home hero, about, services)
  • Catalog: products, albums, collections with images and variants
  • Orders: status timeline with paid/shipped updates; automatic receipts via Nodemailer
  • Shipping: zones/methods configuration reflected in checkout totals

Referral Tracking

  • Middleware captures referrer and UTM parameters into an order referral snapshot for simple campaign attributions across checkout
// apps/server/src/middleware/referral.js (excerpt)
export function referralCaptureMiddleware(req,res,next){
  const referrer = req.get('referer') || req.get('referrer');
  const utm = { source:req.query.utm_source, medium:req.query.utm_medium, campaign:req.query.utm_campaign };
  req._referralContext = { referrer, utm, path: req.originalUrl };
  next();
}

Accessibility

  • Keyboard‑navigable forms and controls; focus states; semantic headings
  • High contrast on dark theme; readable body copy and touch‑target sizes

What made it work

  • A clear separation of CMS content and catalog made updates fast
  • Robust server‑validated checkout kept orders reliable and stock accurate
  • Admin gave the artist full control over merch, content, and SEO without developer support

Deliverables

  • Artist website with albums, products, services, and CMS pages
  • Cart + checkout (Stripe) with order emails
  • Admin: products, collections, pages, shipping, coupons, customers
  • SEO controls and sitemaps

Links