Free shipping on orders over $75  ·  Shop Now

Dynamic Catalog and Product Pages

Part 2 of 6 · PHP · HTML · CSS · JavaScript

What We Are Building in Part 2

Part 1 created the local database and secure PHP foundation. This chapter turns that data into the customer-facing storefront.

Catalog Grid

  • Reusable product cards
  • Pagination
  • Sorting
  • Local product images

Collection Filters

  • URL query parameters
  • Database filtering
  • Active filter states
  • Shareable filtered URLs

Product Page

  • Dynamic product content
  • Image gallery
  • Color swatches
  • Size buttons

Browser Interaction

  • Color switching
  • Size availability
  • Variant selection
  • Related products
The database controls the content. JavaScript controls the interaction.

PHP decides which product and variants exist. JavaScript makes the page feel immediate by switching images, enabling sizes, and tracking the selected variant without reloading the page.

Contents

How a Catalog Request Works

1
Request

The Visitor Opens the Catalog

/index.php?line=Catastrophe+Cats

The URL may contain a collection, sorting option, or page number.

2
Validation

PHP Validates the Filters

PHP accepts only known sort options and treats the collection name as a prepared query value.

3
Database

MySQL Returns the Matching Products

Only active products for the requested collection and page are loaded.

4
Rendering

PHP Builds the Product Grid

Every database row is passed through the same reusable product-card template.

Build the Catalog Query

Start with validated pagination values and a controlled list of sorting options.

PHP
<?php

$page = filter_input(
    INPUT_GET,
    'page',
    FILTER_VALIDATE_INT,
    [
        'options' => [
            'default'   => 1,
            'min_range' => 1,
        ],
    ]
);

$perPage = 24;
$offset  = ($page - 1) * $perPage;

$line = trim((string) ($_GET['line'] ?? ''));
$sort = (string) ($_GET['sort'] ?? 'newest');

$allowedSorts = [
    'newest'   => 's.item_number DESC',
    'likes'    => 's.likes DESC, s.item_number DESC',
    'popular'  => 's.buy_click_count DESC, s.item_number DESC',
    'price-low'  => 's.price ASC, s.item_number DESC',
    'price-high' => 's.price DESC, s.item_number DESC',
];

$orderBy = $allowedSorts[$sort] ?? $allowedSorts['newest'];

$where = [
    's.active = 1',
];

$params = [];

if ($line !== '') {
    $where[] = 'TRIM(s.line) = :line';
    $params[':line'] = $line;
}

$sql = "
    SELECT
        s.id,
        s.item_number,
        s.title,
        s.slug,
        s.line,
        s.category,
        s.short_description,
        s.price,
        s.likes,
        s.buy_click_count
    FROM shirts AS s
    WHERE " . implode(' AND ', $where) . "
    ORDER BY {$orderBy}
    LIMIT :limit
    OFFSET :offset
";

$stmt = $pdo->prepare($sql);

foreach ($params as $key => $value) {
    $stmt->bindValue(
        $key,
        $value,
        PDO::PARAM_STR
    );
}

$stmt->bindValue(
    ':limit',
    $perPage,
    PDO::PARAM_INT
);

$stmt->bindValue(
    ':offset',
    $offset,
    PDO::PARAM_INT
);

$stmt->execute();

$shirts = $stmt->fetchAll();
Do not accept a raw ORDER BY value from the URL.

Prepared statements cannot bind SQL keywords or column names. Use a fixed whitelist that maps public sort names to known SQL fragments.

Add Collection Filters

The filter should create a real URL so it remains shareable, bookmarkable, and usable without JavaScript.

PHP and HTML
<nav class="catalog-filters" aria-label="Product collections">
    <a href="/index.php"
       class="catalog-filter<?= $line === '' ? ' is-active' : '' ?>">
        All Shirts
    </a>

    <?php foreach ($allLines as $collection): ?>
        <?php
        $collectionName = $collection['line'];
        $isActive = $line === $collectionName;
        ?>

        <a href="/index.php?line=<?= urlencode($collectionName) ?>"
           class="catalog-filter<?= $isActive ? ' is-active' : '' ?>">
            <?= htmlspecialchars(
                $collectionName,
                ENT_QUOTES,
                'UTF-8'
            ) ?>

            <span>
                <?= (int) $collection['cnt'] ?>
            </span>
        </a>
    <?php endforeach; ?>
</nav>

Add Sorting

HTML and PHP
<form method="get" class="catalog-sort">
    <?php if ($line !== ''): ?>
        <input type="hidden"
               name="line"
               value="<?= htmlspecialchars(
                   $line,
                   ENT_QUOTES,
                   'UTF-8'
               ) ?>">
    <?php endif; ?>

    <label for="catalog-sort-select">
        Sort by
    </label>

    <select id="catalog-sort-select"
            name="sort"
            onchange="this.form.submit()">
        <option value="newest"
            <?= $sort === 'newest' ? 'selected' : '' ?>>
            Newest
        </option>

        <option value="likes"
            <?= $sort === 'likes' ? 'selected' : '' ?>>
            Most Liked
        </option>

        <option value="popular"
            <?= $sort === 'popular' ? 'selected' : '' ?>>
            Most Purchased
        </option>

        <option value="price-low"
            <?= $sort === 'price-low' ? 'selected' : '' ?>>
            Price: Low to High
        </option>

        <option value="price-high"
            <?= $sort === 'price-high' ? 'selected' : '' ?>>
            Price: High to Low
        </option>
    </select>
</form>

Create a Reusable Product Card

Keep product-card markup in one include so catalog pages, related products, search results, and collection pages all use the same component.

PHP
<?php
/**
 * /includes/product-card.php
 *
 * Expected variable:
 * $shirt
 */

$itemNumber = (int) $shirt['item_number'];

$productUrl = '/shirt.php?item=' . $itemNumber;

$imagePath = "/images/{$itemNumber}/Front, Black.jpg";
$imageFile = $_SERVER['DOCUMENT_ROOT'] . $imagePath;

if (!is_file($imageFile)) {
    $imagePath = "/images/{$itemNumber}/design_transparent.jpg";
}

if (!is_file($_SERVER['DOCUMENT_ROOT'] . $imagePath)) {
    $imagePath = '/imgs/product-placeholder.png';
}
?>

<article class="catalog-card">
    <a href="<?= htmlspecialchars(
        $productUrl,
        ENT_QUOTES,
        'UTF-8'
    ) ?>"
       class="catalog-card-image-link">

        <img src="<?= htmlspecialchars(
            $imagePath,
            ENT_QUOTES,
            'UTF-8'
        ) ?>"
             alt="<?= htmlspecialchars(
                 $shirt['title'],
                 ENT_QUOTES,
                 'UTF-8'
             ) ?>"
             class="catalog-card-image"
             loading="lazy"
             decoding="async">
    </a>

    <div class="catalog-card-body">
        <?php if (!empty($shirt['line'])): ?>
            <span class="catalog-card-line">
                <?= htmlspecialchars(
                    $shirt['line'],
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </span>
        <?php endif; ?>

        <h3 class="catalog-card-title">
            <a href="<?= htmlspecialchars(
                $productUrl,
                ENT_QUOTES,
                'UTF-8'
            ) ?>">
                <?= htmlspecialchars(
                    $shirt['title'],
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </a>
        </h3>

        <?php if (!empty($shirt['short_description'])): ?>
            <p class="catalog-card-desc">
                <?= htmlspecialchars(
                    $shirt['short_description'],
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </p>
        <?php endif; ?>

        <div class="catalog-card-footer">
            <span class="catalog-card-price">
                $<?= number_format(
                    (float) $shirt['price'],
                    2
                ) ?>
            </span>

            <span class="catalog-card-likes">
                <i class="fa-regular fa-heart"
                   aria-hidden="true"></i>

                <?= (int) $shirt['likes'] ?>
            </span>
        </div>
    </div>
</article>

Render the Product Grid

PHP and HTML
<?php if (!$shirts): ?>
    <div class="catalog-empty">
        <h2>No products found</h2>

        <p>
            Try another collection or return to all shirts.
        </p>

        <a href="/index.php" class="hero-cta">
            Shop All Shirts
        </a>
    </div>
<?php else: ?>
    <div class="catalog-grid">
        <?php foreach ($shirts as $shirt): ?>
            <?php include __DIR__ . '/includes/product-card.php'; ?>
        <?php endforeach; ?>
    </div>
<?php endif; ?>

Add Pagination

Count matching products with the same filters used by the catalog query.

PHP
<?php

$countSql = "
    SELECT COUNT(*)
    FROM shirts AS s
    WHERE " . implode(' AND ', $where);

$countStmt = $pdo->prepare($countSql);

foreach ($params as $key => $value) {
    $countStmt->bindValue(
        $key,
        $value,
        PDO::PARAM_STR
    );
}

$countStmt->execute();

$totalProducts = (int) $countStmt->fetchColumn();
$totalPages    = max(
    1,
    (int) ceil($totalProducts / $perPage)
);

function catalogUrl(
    int $page,
    string $line,
    string $sort
): string {
    $query = [
        'page' => $page,
    ];

    if ($line !== '') {
        $query['line'] = $line;
    }

    if ($sort !== 'newest') {
        $query['sort'] = $sort;
    }

    return '/index.php?' . http_build_query($query);
}
HTML and PHP
<?php if ($totalPages > 1): ?>
    <nav class="catalog-pagination"
         aria-label="Product catalog pages">

        <?php if ($page > 1): ?>
            <a href="<?= htmlspecialchars(
                catalogUrl($page - 1, $line, $sort),
                ENT_QUOTES,
                'UTF-8'
            ) ?>">
                Previous
            </a>
        <?php endif; ?>

        <span>
            Page <?= $page ?> of <?= $totalPages ?>
        </span>

        <?php if ($page < $totalPages): ?>
            <a href="<?= htmlspecialchars(
                catalogUrl($page + 1, $line, $sort),
                ENT_QUOTES,
                'UTF-8'
            ) ?>">
                Next
            </a>
        <?php endif; ?>
    </nav>
<?php endif; ?>

Load the Dynamic Product Page

The product page validates the item number, loads the product, variants, and approved reviews, and returns a 404 when the product does not exist.

PHP
<?php

$itemNumber = filter_input(
    INPUT_GET,
    'item',
    FILTER_VALIDATE_INT,
    [
        'options' => [
            'min_range' => 1,
        ],
    ]
);

if (!$itemNumber) {
    http_response_code(404);
    exit('Product not found.');
}

$productStmt = $pdo->prepare("
    SELECT *
    FROM shirts
    WHERE item_number = :item_number
      AND active = 1
    LIMIT 1
");

$productStmt->execute([
    ':item_number' => $itemNumber,
]);

$shirt = $productStmt->fetch();

if (!$shirt) {
    http_response_code(404);
    exit('Product not found.');
}

$variantStmt = $pdo->prepare("
    SELECT
        id,
        color,
        size,
        price,
        enabled,
        available,
        shopify_variant_id,
        printify_variant_id,
        image_url
    FROM shirt_variants
    WHERE shirt_id = :shirt_id
      AND enabled = 1
    ORDER BY
        color ASC,
        FIELD(
            size,
            'XS',
            'S',
            'M',
            'L',
            'XL',
            '2XL',
            '3XL'
        )
");

$variantStmt->execute([
    ':shirt_id' => $shirt['id'],
]);

$variants = $variantStmt->fetchAll();

Resolve Product Images

Use a predictable local image convention and a fallback order.

PHP
<?php

function productImageUrl(
    int $itemNumber,
    string $position,
    string $color
): string {
    $safePosition = preg_replace(
        '/[^A-Za-z0-9 _-]/',
        '',
        $position
    );

    $safeColor = preg_replace(
        '/[^A-Za-z0-9 _-]/',
        '',
        $color
    );

    $candidates = [
        "/images/{$itemNumber}/{$safePosition}, {$safeColor}.jpg",
        "/images/{$itemNumber}/{$safePosition}, {$safeColor}.png",
        "/images/{$itemNumber}/design_transparent.jpg",
        "/images/{$itemNumber}/design_transparent.png",
        '/imgs/product-placeholder.png',
    ];

    foreach ($candidates as $url) {
        $file = $_SERVER['DOCUMENT_ROOT'] . $url;

        if (is_file($file)) {
            return $url;
        }
    }

    return '/imgs/product-placeholder.png';
}

Build one image map for every available color:

PHP
<?php

$colors = [];

foreach ($variants as $variant) {
    $color = $variant['color'];

    if (!isset($colors[$color])) {
        $colors[$color] = [
            'front' => productImageUrl(
                (int) $shirt['item_number'],
                'Front',
                $color
            ),
            'back' => productImageUrl(
                (int) $shirt['item_number'],
                'Back',
                $color
            ),
        ];
    }
}

$defaultColor = array_key_first($colors);
$defaultImage = $colors[$defaultColor]['front']
    ?? '/imgs/product-placeholder.png';

Prepare Variant Data for JavaScript

JavaScript needs a clean list of only the fields used by the interface.

PHP
<?php

$clientVariants = array_map(
    static function (array $variant): array {
        return [
            'id' => (int) $variant['id'],
            'color' => $variant['color'],
            'size' => $variant['size'],
            'price' => (float) $variant['price'],
            'available' => (bool) $variant['available'],
            'shopifyVariantId' => $variant['shopify_variant_id'],
            'imageUrl' => $variant['image_url'],
        ];
    },
    $variants
);
JavaScript Data
<script>
window.productData = {
    variants: <?= json_encode(
        $clientVariants,
        JSON_UNESCAPED_SLASHES |
        JSON_UNESCAPED_UNICODE
    ) ?>,

    images: <?= json_encode(
        $colors,
        JSON_UNESCAPED_SLASHES |
        JSON_UNESCAPED_UNICODE
    ) ?>
};
</script>

Build the Product-Page HTML

HTML and PHP
<section class="product-layout"
         data-product-page>

    <div class="product-gallery">
        <img id="product-main-image"
             src="<?= htmlspecialchars(
                 $defaultImage,
                 ENT_QUOTES,
                 'UTF-8'
             ) ?>"
             alt="<?= htmlspecialchars(
                 $shirt['title'],
                 ENT_QUOTES,
                 'UTF-8'
             ) ?>"
             class="product-main-image">

        <div class="product-thumbnail-row">
            <button type="button"
                    class="product-thumbnail is-active"
                    data-position="front">
                Front
            </button>

            <button type="button"
                    class="product-thumbnail"
                    data-position="back">
                Back
            </button>
        </div>
    </div>

    <div class="product-info">
        <?php if (!empty($shirt['line'])): ?>
            <p class="product-line">
                <?= htmlspecialchars(
                    $shirt['line'],
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </p>
        <?php endif; ?>

        <h1>
            <?= htmlspecialchars(
                $shirt['title'],
                ENT_QUOTES,
                'UTF-8'
            ) ?>
        </h1>

        <p id="product-price"
           class="product-price">
            $<?= number_format(
                (float) $shirt['price'],
                2
            ) ?>
        </p>

        <p class="product-short-description">
            <?= htmlspecialchars(
                $shirt['short_description'],
                ENT_QUOTES,
                'UTF-8'
            ) ?>
        </p>

        <fieldset class="product-option-group">
            <legend>
                Color:
                <span id="selected-color-label">
                    <?= htmlspecialchars(
                        (string) $defaultColor,
                        ENT_QUOTES,
                        'UTF-8'
                    ) ?>
                </span>
            </legend>

            <div class="product-swatches"
                 id="product-swatches">
                <?php foreach (array_keys($colors) as $color): ?>
                    <button type="button"
                            class="product-swatch<?=
                                $color === $defaultColor
                                    ? ' is-active'
                                    : ''
                            ?>"
                            data-color="<?= htmlspecialchars(
                                $color,
                                ENT_QUOTES,
                                'UTF-8'
                            ) ?>"
                            aria-label="Select <?= htmlspecialchars(
                                $color,
                                ENT_QUOTES,
                                'UTF-8'
                            ) ?>">
                        <span>
                            <?= htmlspecialchars(
                                $color,
                                ENT_QUOTES,
                                'UTF-8'
                            ) ?>
                        </span>
                    </button>
                <?php endforeach; ?>
            </div>
        </fieldset>

        <fieldset class="product-option-group">
            <legend>Size</legend>

            <div class="product-sizes"
                 id="product-sizes"></div>
        </fieldset>

        <p id="variant-message"
           class="product-variant-message"
           aria-live="polite"></p>

        <button type="button"
                id="add-to-cart-button"
                class="product-buy-button"
                disabled>
            Select a size
        </button>

        <div class="product-description">
            <?= nl2br(
                htmlspecialchars(
                    $shirt['description'],
                    ENT_QUOTES,
                    'UTF-8'
                )
            ) ?>
        </div>
    </div>
</section>

Add Color and Image Switching

JavaScript
const productData = window.productData;

const mainImage = document.querySelector(
    '#product-main-image'
);

const colorLabel = document.querySelector(
    '#selected-color-label'
);

const swatches = Array.from(
    document.querySelectorAll('.product-swatch')
);

const thumbnails = Array.from(
    document.querySelectorAll('.product-thumbnail')
);

let selectedColor = swatches[0]?.dataset.color ?? '';
let selectedPosition = 'front';

function updateProductImage() {
    const colorImages = productData.images[selectedColor];

    if (!colorImages) {
        return;
    }

    const nextImage =
        colorImages[selectedPosition] ||
        colorImages.front;

    if (nextImage) {
        mainImage.src = nextImage;
    }
}

swatches.forEach((swatch) => {
    swatch.addEventListener('click', () => {
        selectedColor = swatch.dataset.color;

        swatches.forEach((item) => {
            item.classList.toggle(
                'is-active',
                item === swatch
            );
        });

        colorLabel.textContent = selectedColor;

        updateProductImage();
        renderSizeButtons();
        clearSelectedVariant();
    });
});

thumbnails.forEach((thumbnail) => {
    thumbnail.addEventListener('click', () => {
        selectedPosition = thumbnail.dataset.position;

        thumbnails.forEach((item) => {
            item.classList.toggle(
                'is-active',
                item === thumbnail
            );
        });

        updateProductImage();
    });
});

Add Size Availability

Build the size buttons from variants available for the currently selected color.

JavaScript
const sizeOrder = [
    'XS',
    'S',
    'M',
    'L',
    'XL',
    '2XL',
    '3XL'
];

const sizeContainer = document.querySelector(
    '#product-sizes'
);

const variantMessage = document.querySelector(
    '#variant-message'
);

const buyButton = document.querySelector(
    '#add-to-cart-button'
);

let selectedVariant = null;

function variantsForSelectedColor() {
    return productData.variants.filter(
        (variant) => variant.color === selectedColor
    );
}

function renderSizeButtons() {
    const colorVariants = variantsForSelectedColor();

    const variantsBySize = new Map(
        colorVariants.map((variant) => [
            variant.size,
            variant
        ])
    );

    sizeContainer.innerHTML = '';

    sizeOrder.forEach((size) => {
        const variant = variantsBySize.get(size);
        const button = document.createElement('button');

        button.type = 'button';
        button.className = 'product-size';
        button.textContent = size;
        button.dataset.size = size;

        if (!variant || !variant.available) {
            button.disabled = true;
            button.classList.add('is-unavailable');
            button.setAttribute(
                'aria-label',
                `${size} unavailable`
            );
        } else {
            button.addEventListener('click', () => {
                selectVariant(variant, button);
            });
        }

        sizeContainer.appendChild(button);
    });
}

Track the Selected Variant

JavaScript
const productPrice = document.querySelector(
    '#product-price'
);

function selectVariant(variant, button) {
    selectedVariant = variant;

    document
        .querySelectorAll('.product-size')
        .forEach((item) => {
            item.classList.toggle(
                'is-active',
                item === button
            );
        });

    productPrice.textContent =
        `$${variant.price.toFixed(2)}`;

    variantMessage.textContent =
        `${variant.color}, size ${variant.size} selected.`;

    buyButton.disabled = false;
    buyButton.textContent = 'Add to Cart';

    buyButton.dataset.shopifyVariantId =
        variant.shopifyVariantId || '';
}

function clearSelectedVariant() {
    selectedVariant = null;

    buyButton.disabled = true;
    buyButton.textContent = 'Select a size';
    buyButton.dataset.shopifyVariantId = '';

    variantMessage.textContent = '';
}

renderSizeButtons();
The Shopify variant ID is present on the page, but Part 2 does not create the cart yet. Part 3 will send that selected ID to a server-side cart endpoint.

Style the Catalog and Product Page

These styles provide a practical starting point. They can be merged into the existing catalog and product stylesheets.

CSS
.catalog-controls {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    margin-bottom: 2rem;
}

.catalog-filters {
    display: flex;
    flex-wrap: wrap;
    gap: .6rem;
}

.catalog-filter {
    display: inline-flex;
    align-items: center;
    gap: .45rem;
    padding: .65rem .85rem;
    border: 1px solid #d8d8d8;
    border-radius: 999px;
    background: #fff;
    color: #111;
    text-decoration: none;
    font-size: .82rem;
    font-weight: 700;
}

.catalog-filter.is-active {
    border-color: #111;
    background: #111;
    color: #fff;
}

.catalog-grid {
    display: grid;
    grid-template-columns: repeat(
        4,
        minmax(0, 1fr)
    );
    gap: 1.25rem;
}

.catalog-card {
    overflow: hidden;
    border: 1px solid #dedede;
    border-radius: 12px;
    background: #fff;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .05);
}

.catalog-card-image-link {
    display: block;
    aspect-ratio: 1 / 1;
    background: #f5f5f5;
}

.catalog-card-image {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
}

.catalog-card-body {
    padding: 1rem;
}

.catalog-card-line {
    display: block;
    margin-bottom: .35rem;
    color: #777;
    font-size: .7rem;
    font-weight: 800;
    letter-spacing: .08em;
    text-transform: uppercase;
}

.catalog-card-title {
    margin: 0 0 .5rem;
    font-size: 1rem;
}

.catalog-card-title a {
    color: inherit;
    text-decoration: none;
}

.catalog-card-desc {
    margin: 0 0 .8rem;
    color: #555;
    font-size: .82rem;
    line-height: 1.5;
}

.catalog-card-footer {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    font-weight: 800;
}

.catalog-pagination {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 1rem;
    margin-top: 2rem;
}

.product-layout {
    display: grid;
    grid-template-columns:
        minmax(0, 1.1fr)
        minmax(320px, .9fr);
    gap: 3rem;
    align-items: start;
}

.product-main-image {
    width: 100%;
    display: block;
    border-radius: 12px;
    background: #f5f5f5;
}

.product-thumbnail-row,
.product-swatches,
.product-sizes {
    display: flex;
    flex-wrap: wrap;
    gap: .65rem;
    margin-top: 1rem;
}

.product-thumbnail,
.product-swatch,
.product-size {
    min-height: 42px;
    padding: .65rem .8rem;
    border: 1px solid #ccc;
    border-radius: 8px;
    background: #fff;
    color: #111;
    cursor: pointer;
}

.product-thumbnail.is-active,
.product-swatch.is-active,
.product-size.is-active {
    border-color: #111;
    background: #111;
    color: #fff;
}

.product-size:disabled,
.product-size.is-unavailable {
    cursor: not-allowed;
    opacity: .35;
    text-decoration: line-through;
}

.product-option-group {
    margin: 1.5rem 0;
    padding: 0;
    border: 0;
}

.product-option-group legend {
    margin-bottom: .7rem;
    font-weight: 800;
}

.product-buy-button {
    width: 100%;
    min-height: 52px;
    border: 0;
    border-radius: 8px;
    background: #111;
    color: #fff;
    font-weight: 800;
    cursor: pointer;
}

.product-buy-button:disabled {
    cursor: not-allowed;
    opacity: .45;
}

@media (max-width: 1000px) {
    .catalog-grid {
        grid-template-columns: repeat(
            3,
            minmax(0, 1fr)
        );
    }
}

@media (max-width: 760px) {
    .catalog-controls,
    .product-layout {
        grid-template-columns: 1fr;
        display: grid;
    }

    .catalog-grid {
        grid-template-columns: repeat(
            2,
            minmax(0, 1fr)
        );
    }
}

@media (max-width: 520px) {
    .catalog-grid {
        grid-template-columns: 1fr;
    }
}

Accessibility and Fallback Behavior

Keep

Accessible Behavior

Real buttons and links Swatches and sizes should use buttons so they work with keyboards.
Live status messages Use an aria-live region to announce the selected color, size, and availability.
Useful alt text Product images should describe the design and selected garment color.
Avoid

Fragile Behavior

Color-only meaning Show the color name, not only a visual swatch.
Fake disabled states Use the disabled attribute so unavailable sizes cannot be selected.
JavaScript-only filtering Collection URLs should still load the correct products from PHP.

Part 2 Storefront Checklist

  • Catalog filters use real URLs.
  • Collection values are passed through prepared statements.
  • Sort options come from a fixed whitelist.
  • The catalog query loads only active products.
  • The catalog uses pagination or incremental loading.
  • Product cards use one reusable include.
  • Product images use predictable local paths and fallbacks.
  • Product-card images use lazy loading.
  • The product page validates the item number.
  • Missing or inactive products return a 404 status.
  • Only enabled variants are sent to the page.
  • Unavailable variants cannot be selected.
  • Color selection updates the product image.
  • Color selection rebuilds size availability.
  • The selected variant controls the displayed price.
  • The selected Shopify variant ID is stored for checkout.
  • Buttons work with a keyboard.
  • Selection changes are announced through an aria-live region.
  • Related products exclude the current product.
  • JavaScript does not replace server-side validation.

Dynamic Catalog and Product Pages FAQ

How does one PHP product page display every shirt?

The product page reads an item number from the URL, validates it, loads the matching database record, loads its available variants and images, and then renders the same template with different data.

Should collection filters reload the page or use JavaScript?

Either approach works. Server-side query parameters are simpler, indexable, and reliable. JavaScript can improve the interaction, but the filtered URL should still work without JavaScript.

How should product card images be selected?

Use a predictable local image path or a stored image record. A common approach is to prefer a default front mockup and fall back to a transparent design or placeholder when the preferred image is missing.

How do color buttons know which image to display?

Each available color can include an image URL or a predictable image filename. JavaScript reads the selected color and updates the main product image and active swatch state.

How should unavailable sizes be handled?

Sizes should be enabled only when a matching variant exists and is both enabled and available. Unavailable sizes should be visibly disabled and excluded from checkout.

Why should variants be passed to JavaScript as JSON?

JSON lets the browser work with the same variant data already loaded by PHP. JavaScript can then update size availability, price, image, and Shopify variant IDs without another database request.

How are related products selected?

A simple related-product query can select active products from the same collection or category while excluding the current item. More advanced systems can use tags, themes, popularity, or manual relationships.

Should product pages use item numbers or slugs?

Item numbers are simple and stable. Slugs create cleaner URLs. A store can support both by using a slug publicly while retaining the item number as the internal business identifier.

How many products should load on the catalog page?

Use pagination or incremental loading instead of loading the entire catalog. The exact number depends on image weight and layout, but 12 to 24 products per request is a common starting point.

Does JavaScript replace server-side validation?

No. JavaScript improves the interface, but the server must still validate the product, variant, quantity, price assumptions, and cart request.

Continue to Part 3

The customer can now browse collections, open a product, select a color and size, and identify the correct Shopify variant. Part 3 turns that selected variant into a Shopify cart and checkout URL.

0
cart