Fountain

Using javascript

Fountain is designed to work without JavaScript by default, using standard HTML forms. However, it provides a JavaScript API for building dynamic, enhanced user experiences when adding and removing items from the cart.

How It Works

Fountain routes detect JavaScript requests via the X-Requested-With: fountain header. When this header is present, routes return JSON responses instead of redirecting. This allows you to:

  • Add/remove items without page reloads
  • Update cart UI dynamically
  • Build single-page checkout experiences
  • Provide instant feedback to users

API Endpoints

Add to Cart

Endpoint: site()->cartAddUrl() (typically /cart-add)
Methods: GET, POST
Header: X-Requested-With: fountain

Parameters

Parameter Type Description
add string Font UUID (required for JS requests)
uri string Font URI/slug (optional)
license string License ID(s), separated by configured separator
addon string Addon ID(s), separated by configured separator
csrf string CSRF token (required for POST requests)

Example: Add Font with License

async function addToCart(fontUuid, licenseId) {
  const separator = '|'; // Default Fountain separator
  const params = new URLSearchParams({
    add: fontUuid,
    license: `${fontUuid}${separator}${licenseId}`
  });

  const response = await fetch(`/cart-add?${params}`, {
    headers: {
      'X-Requested-With': 'fountain'
    }
  });

  const data = await response.json();
  return data;
}

Example: Add Font with License and Addon

async function addToCartWithAddon(fontUuid, licenseId, addonId) {
  const separator = '|';
  const params = new URLSearchParams({
    add: fontUuid,
    license: `${fontUuid}${separator}${licenseId}`,
    addon: `${fontUuid}${separator}${addonId}`
  });

  const response = await fetch(`/cart-add?${params}`, {
    headers: {
      'X-Requested-With': 'fountain'
    }
  });

  return await response.json();
}

Remove from Cart

Endpoint: site()->cartRemoveUrl() (typically /cart-remove)
Methods: GET, POST
Header: X-Requested-With: fountain

Parameters

Parameter Type Description
remove string UUID to remove entire product, or uuid|license to remove specific license

Example: Remove Entire Product

async function removeProduct(fontUuid) {
  const params = new URLSearchParams({
    remove: fontUuid
  });

  const response = await fetch(`/cart-remove?${params}`, {
    headers: {
      'X-Requested-With': 'fountain'
    }
  });

  return await response.json();
}

Example: Remove Specific License

async function removeLicense(fontUuid, licenseId) {
  const separator = '|';
  const params = new URLSearchParams({
    remove: `${fontUuid}${separator}${licenseId}`
  });

  const response = await fetch(`/cart-remove?${params}`, {
    headers: {
      'X-Requested-With': 'fountain'
    }
  });

  return await response.json();
}

Response Format

Both endpoints return JSON when the X-Requested-With: fountain header is present. The response includes the updated cart state:

{
  "licensee": null,
  "subtotal": "99.00",
  "cart": {
    "items": [
      {
        "uuid": "abc123def456",
        "licenses": ["desktop-license"],
        "addons": ["webfont-addon"]
      }
    ]
  }
}

Response Fields

Field Type Description
licensee string|null Licensee name if set
subtotal string Cart subtotal as formatted string
cart.items array Array of cart items
cart.items[].uuid string Font UUID
cart.items[].licenses array Array of license IDs
cart.items[].addons array Array of addon IDs

Complete Example

Here's a complete example of a cart management module:

class FountainCart {
  constructor() {
    this.separator = '|'; // Match Kirby option
    this.baseUrl = window.location.origin;
  }

  async request(url, params) {
    const queryString = new URLSearchParams(params).toString();
    const response = await fetch(`${this.baseUrl}${url}?${queryString}`, {
      headers: {
        'X-Requested-With': 'fountain'
      }
    });

    if (!response.ok) {
      throw new Error(`Cart request failed: ${response.status}`);
    }

    return await response.json();
  }

  async addProduct(uuid, licenseId = null, addonId = null) {
    const params = { add: uuid };

    if (licenseId) {
      params.license = `${uuid}${this.separator}${licenseId}`;
    }

    if (addonId) {
      params.addon = `${uuid}${this.separator}${addonId}`;
    }

    return await this.request('/cart-add', params);
  }

  async removeProduct(uuid) {
    return await this.request('/cart-remove', {
      remove: uuid
    });
  }

  async removeLicense(uuid, licenseId) {
    return await this.request('/cart-remove', {
      remove: `${uuid}${this.separator}${licenseId}`
    });
  }

  updateUI(cartData) {
    // Update your UI with the cart data
    console.log('Cart updated:', cartData);
    document.querySelector('.cart-count').textContent =
      cartData.cart.items.length;
    document.querySelector('.cart-subtotal').textContent =
      cartData.subtotal;
  }
}

// Usage
const cart = new FountainCart();

document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
  btn.addEventListener('click', async (e) => {
    e.preventDefault();
    const uuid = btn.dataset.uuid;
    const license = btn.dataset.license;

    try {
      const data = await cart.addProduct(uuid, license);
      cart.updateUI(data);
    } catch (error) {
      console.error('Failed to add to cart:', error);
    }
  });
});

Important Notes

CSRF Protection

POST requests require a valid CSRF token. For GET requests (recommended for cart operations), CSRF is not required.

Debug Mode

Important: The JSON response is only returned when Kirby's debug mode is enabled. In production (debug: false), the getJson() method returns null.

To enable debug mode in your config.php:

return [
  'debug' => true
];

Separator Configuration

The separator character used to join values (default: |) can be configured in your Kirby config:

return [
  'nymarktype.fountain.separator' => '|'
];

Input Validation

All routes include strict input validation:

  • UUIDs must match pattern: /^[a-zA-Z0-9\-]{8,50}$/
  • URIs must match pattern: /^[a-zA-Z0-9\-_\/]+$/
  • Suspicious input is logged with IP address

Error Handling

Always wrap cart operations in try-catch blocks to handle network errors, validation failures, or server issues gracefully.

Progressive Enhancement

Fountain is built with progressive enhancement in mind. Start with working HTML forms, then enhance with JavaScript:

<!-- Works without JavaScript -->
<form action="/cart-add" method="post">
  <input type="hidden" name="product_<?= $font->uuid() ?>"
         value="<?= $font->uuid() ?>|<?= $font->uri() ?>">
  <input type="hidden" name="license_<?= $font->uuid() ?>"
         value="<?= $font->uuid() ?>|desktop-license">
  <button type="submit">Add to Cart</button>
</form>

<script>
// Enhance with JavaScript
document.querySelector('form').addEventListener('submit', async (e) => {
  e.preventDefault();
  // Use JavaScript API instead
  const data = await cart.addProduct(uuid, licenseId);
  cart.updateUI(data);
});
</script>

This ensures your cart works for all users, regardless of JavaScript availability.