Skip to content

Shop & Variant Quantity Limits

UberLotto v2 enforces quantity limits at two levels — shop-wide (global and per-product-type) and per-variant. The system uses a dual-namespace approach (cart.* and limits.*) with automatic fallback, plus integration with Shopify's native quantityRule.

Architecture

Implementation

The quantity limits system is implemented in app/lib/shop-limits.server.ts and queried via the Storefront API GraphQL in app/graphql/game-detail/GameDetailQuery.ts.

Interfaces

typescript
// app/lib/shop-limits.server.ts

interface ShopLimits {
  global?: number;
  lottery?: number;
  scratchCard?: number;
  game?: number;
}

The ShopLimits interface represents the resolved limits after the fallback chain is applied. All fields are optional — if no metafield is set, the limit is undefined (no restriction).

Dual-Namespace System

The system maintains two parallel sets of metafields:

PurposeCurrent NamespaceLegacy Namespace
Standardcart.*limits.*
PriorityChecked firstFallback only

This exists for backward compatibility. New configurations should use the cart.* namespace.

Shop-Level Metafields

Current (cart.*)

MetafieldDescription
cart.max_quantity_globalMaximum quantity across all product types
cart.max_quantity_lotteryMaximum quantity for lottery products
cart.max_quantity_scratch_cardMaximum quantity for scratch card products
cart.max_quantity_gameMaximum quantity for game products

Legacy (limits.*)

MetafieldDescription
limits.global_limitLegacy global maximum
limits.lottery_limitLegacy lottery maximum
limits.scratch_card_limitLegacy scratch card maximum
limits.game_limitLegacy game maximum

Variant-Level Metafields

MetafieldStatusDescription
cart.max_quantityCurrentPer-variant maximum quantity
limits.max_quantityLegacyPer-variant maximum (fallback)

Priority Chain

Shop-Level Resolution

For each product type, the system resolves limits in this order:

1. cart.max_quantity_{type}     (e.g., cart.max_quantity_lottery)
2. limits.{type}_limit         (e.g., limits.lottery_limit)
3. cart.max_quantity_global     (global fallback)
4. limits.global_limit         (legacy global fallback)

Implementation in getShopLimitByType():

typescript
function getShopLimitByType(
  shopLimits: ShopLimits,
  productType: string,
): number | undefined {
  switch (productType?.toLowerCase()) {
    case 'lottery':
      return shopLimits.lottery ?? shopLimits.global;
    case 'scratch-card':
      return shopLimits.scratchCard ?? shopLimits.global;
    case 'game':
      return shopLimits.game ?? shopLimits.global;
    default:
      return shopLimits.global;
  }
}

TIP

The parseWithFallback() helper in shop-limits.server.ts resolves cart.*limits.* for each category before the type-to-global fallback runs. This means cart.max_quantity_lottery takes priority over limits.lottery_limit, which takes priority over cart.max_quantity_global.

Variant-Level Resolution

For individual variants, the priority is:

1. Shopify native quantityRule.maximum   (highest priority)
2. cart.max_quantity                      (variant metafield)
3. limits.max_quantity                    (legacy variant metafield)

The native Shopify quantityRule is set in the variant's inventory settings and is the recommended approach for new configurations.

Shopify Native quantityRule

Shopify's built-in quantity rules provide:

PropertyTypeDescription
maximumIntegerMaximum purchasable quantity
minimumIntegerMinimum purchasable quantity
incrementIntegerQuantity step (e.g., buy in multiples of 5)

How to Set

  1. Go to Shopify Admin > Products > [Product] > Variants
  2. Edit the variant
  3. Under Inventory, set Quantity rules:
    • Minimum: 1
    • Maximum: 10 (or desired limit)
    • Increment: 1

GraphQL Response

graphql
variants(first: 250) {
  nodes {
    quantityRule {
      maximum    # null if not set
      minimum    # defaults to 1
      increment  # defaults to 1
    }
  }
}

WARNING

If quantityRule.maximum is set, it always takes precedence over metafield-based limits. This is by design — native Shopify rules are the most reliable enforcement mechanism.

How to Configure

Setting Shop-Level Limits

Shop metafields must be set via the Shopify Admin API or a metafield editor app, as the Shopify Admin UI does not expose shop-level metafields directly.

Via Shopify Admin API:

bash
# Set global limit to 20
POST /admin/api/2024-01/metafields.json
{
  "metafield": {
    "namespace": "cart",
    "key": "max_quantity_global",
    "value": "20",
    "type": "number_integer"
  }
}
bash
# Set lottery-specific limit to 10
POST /admin/api/2024-01/metafields.json
{
  "metafield": {
    "namespace": "cart",
    "key": "max_quantity_lottery",
    "value": "10",
    "type": "number_integer"
  }
}

Setting Variant-Level Limits

Option 1: Native quantityRule (Recommended)

  1. Edit the variant in Shopify Admin
  2. Set quantity rules under Inventory

Option 2: Metafield

  1. Edit the variant in Shopify Admin
  2. Scroll to Metafields
  3. Set cart.max_quantity to the desired integer value

Example Configuration

A typical lottery product setup:

LevelMetafieldValueEffect
Shopcart.max_quantity_global50No customer can add more than 50 of any single item
Shopcart.max_quantity_lottery10Lottery products capped at 10
VariantquantityRule.maximum5This specific variant capped at 5

The effective limit for this variant would be 5 (the most restrictive applicable limit).

Caching

Shop limits are fetched using Shopify's CacheLong() strategy:

typescript
const {shop} = await storefront.query(SHOP_LIMITS_QUERY, {
  cache: storefront.CacheLong(),
});

This means changes to shop-level metafields may take time to propagate. To force a refresh, clear the Hydrogen cache or wait for the cache TTL to expire.

Parsing Behavior

The parseMetafieldValue() function handles edge cases:

  • null or undefined metafield → returns null (no limit)
  • Empty string → returns null
  • "0" or negative values → returns null (treated as no limit)
  • Valid positive integer string → returns the parsed number
typescript
function parseMetafieldValue(metafield?: ShopMetafield | null): number | null {
  if (!metafield?.value) return null;
  const parsed = parseInt(metafield.value, 10);
  return isNaN(parsed) || parsed <= 0 ? null : parsed;
}

Migration Path

If you're migrating from the legacy limits.* namespace:

  1. Set the new cart.* metafields with the same values
  2. Leave the limits.* metafields in place as fallback
  3. Verify the application reads the correct values (check debug logs)
  4. Optionally remove limits.* metafields after confirming

TIP

The system logs which source was used for each limit at debug level. Check server logs for messages like Set lottery limit: 10 (source: primary) to confirm the correct namespace is being read.

UberLotto Technical Documentation