Acme Store Docs
Features

Storefront

The customer-facing shopping experience: catalog, search, product pages, and preferences.

The storefront is the public, customer-facing side of the boilerplate: the landing page, the product catalog with search and category filtering, individual product pages, and the header preferences (language, currency, brand theme) that follow a shopper across the whole site.

Everything here works out of the box against Firebase with no payment provider configured. The default flow is quote-based: shoppers browse the catalog, add items to a cart, and submit a quote request. See Cart & Quotes for the checkout side. Prices still render everywhere, but no payment is required unless you opt into Stripe.

Routes are locale-prefixed under src/app/[locale]/. In this doc the paths are written without the prefix (e.g. /products); links inside the app are localized automatically by useLocalePath() / localizeHref().

Landing page

The home page (src/app/[locale]/page.tsx) is a Server Component that resolves the active locale, builds a translator, and renders six sections in order:

OrderSectionComponentNotes
1Herohome/Hero.tsxHeadline, subtitle, two CTAs, offset photo pair
2Featured productshome/Products.tsxCurated products from the home-products collection
3Shop by categoryhome/ProductGroups.tsxBento grid of productCategories
4Testimonialshome/Testimonials.tsxThree quotes from the dictionary
5Newsletter signuphome/NewsletterSignup.tsxEmail capture into Firestore
6Contacthome/ContactUs.tsxContact details + message form

Hero

The hero renders a localized title/subtitle plus a primary and secondary CTA. Out of the box the primary CTA links to /products and the secondary to /about:

src/app/[locale]/page.tsx
<Hero
  title={t("home.heroTitle")}
  subtitle={t("home.heroSubtitle")}
  imageAlt={t("home.heroImageAlt")}
  primaryCta={{ label: t("home.heroPrimaryCta"), href: "/products" }}
  secondaryCta={{ label: t("home.heroSecondaryCta"), href: "/about" }}
  locale={locale}
/>

The subtitle supports lightweight **bold** markup, and all copy comes from the i18n dictionary (t("home.*")). The placeholder photography is served from picsum.photos seeds — swap the Image src values in Hero.tsx for your own assets.

The featured strip fetches its products through the commerce layer, not the raw catalog:

src/components/home/Products.tsx
const fetchedProducts = await commerce.products.listFeatured();

listFeatured() reads the home-products Firestore collection — a curated set you manage from the admin dashboard, separate from the main products catalog. Because a featured entry is stored under its own auto-generated document id, each one carries a productId field pointing back at the catalog document. The card uses that catalog id for links and add-to-cart:

src/components/home/Products.tsx
const product: HomeProduct = {
  ...homeProduct,
  id: homeProduct.productId ?? homeProduct.id,
};

While loading, six ProductCardSkeletons are shown; cards fade up on scroll (respecting prefers-reduced-motion).

Shop by category

ProductGroups maps over productCategories from src/config/site.ts into a responsive bento grid. The six categories shipped in the boilerplate are:

NameSlug
Audioaudio
Wearableswearables
Smart Homesmart-home
Camerascameras
Computingcomputing
Accessoriesaccessories

Each card links into the catalog filtered to that category (/products?category=<slug>). Edit or extend the list in src/config/site.ts — the grid, catalog chips, and product-page category tags all read from the same source.

Newsletter signup

The newsletter form validates the email with Zod and writes directly to the newsletter_signups Firestore collection:

src/components/home/NewsletterSignup.tsx
await addDoc(collection(db, "newsletter_signups"), {
  email: values.email,
  timestamp: new Date(),
});

On success it swaps to a confirmation message; on failure it surfaces a form error. This is a plain Firestore write and does not require Resend or any email provider.

Contact

The contact section renders your store details from siteConfig.contact (email, phone, address) as mailto:/tel: links, alongside a name/email/message form.

The contact form is intentionally a stub — on submit it just shows a success state. Wire handleSubmit in ContactUs.tsx to your backend (an API route, a form service, or Firestore) before relying on it in production.

Product catalog

The catalog lives at /products (src/app/[locale]/products/page.tsx). The page is a thin Server Component that renders the client-side ProductCatalog inside a <Suspense> boundary — required because the catalog reads ?category= via useSearchParams():

src/app/[locale]/products/page.tsx
<Suspense fallback={/* centered spinner */}>
  <ProductCatalog />
</Suspense>

ProductCatalog (src/components/product/ProductCatalog.tsx) drives three behaviors: category filtering, cursor pagination, and name search.

Category filtering

The active category comes from the URL: searchParams.get("category") ?? "all". A chip row renders an All chip plus one chip per configured category, each a link to /products?category=<slug>. The active chip is highlighted and marked aria-current="page".

The page heading reflects the active category name (or "All products" for all), resolved from productCategories.

Pagination

Category browsing is server-cursor-paginated through the Firebase facade:

src/components/product/ProductCatalog.tsx
const result = await FirebaseDBService.fetchProductsWithPagination(
  PAGE_SIZE, "", "productName", activeCategory, "all", after
);

Key constants:

ConstantValueMeaning
PAGE_SIZE12Products fetched per page

Results accumulate into a single grid. A footer shows Showing {shown} of {total}, and a Load more button appears only while products.length < totalCount and a cursor exists. The button is disabled and shows a loading label while the next page is in flight.

Typing in the search box runs a debounced, prefix-based name search that is independent of the category grid. When a search is active it replaces the paginated grid with a results grid.

ConstantValueMeaning
SEARCH_MIN_CHARS2Minimum characters before a search runs
SEARCH_DEBOUNCE_MS300Debounce delay on the input
SEARCH_MAX_RESULTS24Maximum results returned

The query goes through the commerce layer:

src/components/product/ProductCatalog.tsx
commerce.products.search(searchTerm, SEARCH_MAX_RESULTS)

Under the Firebase adapter this is a prefix range query on productNameLower — a derived, lowercased copy of productName that the data layer maintains automatically on write. Search matches the start of the product name only, and does not combine with category or brand filters (that would require composite indexes). A monotonic request id guards against stale responses overwriting newer ones.

The UI shows a result count, a clear ("X") button, skeletons while searching, and an empty state with a Clear action when nothing matches.

Search is name-prefix only by design, to stay index-free on Firestore. For fuzzy or full-text search, point commerce.products.search at a dedicated search backend behind the same interface — see Commerce Layer.

Product detail page

Each product renders at /products/[id] (src/app/[locale]/products/[id]/page.tsx), a client component that loads the product by id:

src/app/[locale]/products/[id]/page.tsx
commerce.products.getById(params.id)

If the fetch resolves to null (or throws), a localized "not found" screen with a back-to-products button is shown. While loading, a centered spinner renders.

What it displays

  • Image — the selected variant's imageURL, else primaryImageURL, else /placeholder.svg.
  • Brand, name, and item code — the item code is the product id.
  • Price — formatted for the shopper's active currency via formatMoneyIn(displayPrice, currency).
  • Stock availability — a colored dot + label driven by stock level (see below).
  • Category tags — each categories slug links to /products?category=<slug>, labeled from productCategories.
  • Description and, when present, a datasheet link (product.pdfLink) opening in a new tab.
  • Add to cart — via AddToCartButton; see Cart & Quotes.
  • JSON-LDProductJsonLd emits structured data for SEO.

Stock indicator

The StockAvailability helper reads the resolved stock. When stock is undefined, inventory is not tracked and nothing renders:

StockLabelColor
undefined(nothing rendered)
0Out of stockRed
15Only N leftAmber
> 5In stockGreen

Variants

If a product has variants, the page derives one selector group per option axis (from the product's options, or inferred from the variants when absent). Selecting a value in every group resolves a specific variant, and the displayed price, stock, and image switch to that variant's values. Until every axis is chosen, Add to cart is disabled with a "select options" label. Products without variants skip all of this and behave as a simple product.

When the product has categories, related items are fetched from the Firebase facade:

src/app/[locale]/products/[id]/page.tsx
const relatedProducts = await FirebaseDBService.getRelatedProducts(fetched);

getRelatedProducts runs an array-contains-any query on categories (limited to 3 documents), the current product is filtered out, and up to three cards render in a "Related products" section beneath the detail (the grid itself slices to a max of four). If none match, the section is omitted.

Header preferences

The header (src/components/home/Header.tsx) exposes a Preferences popover (components/preferences/PreferencesMenu.tsx) with three controls: language, currency, and brand theme. On mobile these move into the hamburger menu.

Each control is cookie-backed and resolved server-side. Changing one writes a first-party cookie on the client and calls router.refresh() so server-rendered output (translated copy, prices, brand colors) re-renders with the new choice.

PreferenceCookieClient controlServer resolverDefault
LanguagelocaleLocaleSwitchergetActiveLocale()en
CurrencycurrencyCurrencySwitchergetActiveCurrency()USD
Brand themethemeThemeSwitchergetActivePresetName()default

All three cookies are written with path=/ and a ~1 year max-age (SameSite=Lax), so the choice persists across the whole site and future visits. Each server resolver reads the cookie via next/headers and validates it against siteConfig — an absent or unknown value falls back to the default.

The server resolvers live in src/lib/i18n/server.ts, src/lib/currency/server.ts, and src/lib/theme/server.ts. They import next/headers, so never import them from a Client Component.

Language

Locales are declared in siteConfig.locales. The boilerplate ships two:

CodeLabel
enEnglish
esEspañol

Every user-facing string resolves through a keyed dictionary (src/lib/i18n), so adding a language is a data change: drop a src/lib/i18n/<locale>.ts dictionary, register it, and add it to siteConfig.locales. See Internationalization.

Currency

Prices are stored in the store's base currency (USD) and converted at display time by formatMoney / formatMoneyIn. siteConfig.currencies ships four options — the first must be the base (rate 1):

CodeLocaleRateLabel
USDen-US1USD ($)
EURde-DE0.92EUR (€)
GBPen-GB0.79GBP (£)
JPYja-JP155JPY (¥)

The rates in siteConfig.currencies are static demo values. Wire a live FX source before using multi-currency pricing in production.

Brand theme

The theme control switches the four --brand* CSS custom properties at runtime by choosing a preset from siteConfig.theme.presets. Three presets ship:

PresetBrandAccent
defaultDeep navyVivid orange
emeraldGreenGreen
violetPurplePurple

Selecting a preset persists the theme cookie and refreshes the server tree, which re-runs the server-rendered ThemeStyle so the injected --brand* variables update live. Because both light and dark mode read the same tokens, restyling is a one-place change. See Theming and Site Config.

On this page