// Shop API, 25.12.04
// Fetch products from Shopify for the products carousel
// Credentials stored in MongoDB to avoid Netlify's 4KB env var limit
// Now with Redis caching (10 min TTL) to avoid Shopify rate limits
import { respond } from "../../backend/http.mjs";
import { connect } from "../../backend/database.mjs";
import { getOrCompute, CACHE_KEYS, CACHE_TTLS } from "../../backend/cache.mjs";
// Collection ID for "Home page" (frontpage)
const HOME_COLLECTION_ID = 369657512117;
// Cache credentials in memory for warm function invocations
let cachedCredentials = null;
async function getShopifyCredentials() {
if (cachedCredentials) return cachedCredentials;
const { db } = await connect();
const secrets = await db.collection("secrets").findOne({ _id: "shopify" });
if (!secrets) {
throw new Error("Shopify credentials not found in database");
}
cachedCredentials = {
domain: secrets.storeDomain,
token: secrets.adminAccessToken,
};
return cachedCredentials;
}
export async function handler(event, context) {
// Handle CORS preflight
if (event.httpMethod === "OPTIONS") {
return respond(200, {});
}
if (event.httpMethod !== "GET") {
return respond(405, { message: "Method Not Allowed" });
}
try {
// Check if client wants fresh data (bypass cache)
const bypassCache = event.queryStringParameters?.fresh === 'true';
if (bypassCache) {
console.log("🛒 Shop: bypassing cache (fresh=true)");
return respond(200, await computeShopProducts());
}
// Use cached shop data (10 min TTL)
const shopData = await getOrCompute(
CACHE_KEYS.SHOP,
computeShopProducts,
CACHE_TTLS.SHOP
);
return respond(200, shopData);
} catch (error) {
console.error(`❌ Shop API error:`, error);
return respond(500, { message: error.message || String(error) });
}
}
// Extracted shop computation for caching
async function computeShopProducts() {
const { domain: SHOPIFY_STORE_DOMAIN, token: SHOPIFY_ADMIN_ACCESS_TOKEN } =
await getShopifyCredentials();
// Get products from the Home page collection
const productsRes = await fetch(
`https://${SHOPIFY_STORE_DOMAIN}/admin/api/2024-10/collections/${HOME_COLLECTION_ID}/products.json?limit=50`,
{
headers: {
"X-Shopify-Access-Token": SHOPIFY_ADMIN_ACCESS_TOKEN,
},
}
);
if (!productsRes.ok) {
throw new Error(`Shopify API error: ${productsRes.status}`);
}
const productsData = await productsRes.json();
// Process products into a simplified format for the carousel
const products = await Promise.all(
productsData.products.map(async (p) => {
// Get inventory for this product
const variantsRes = await fetch(
`https://${SHOPIFY_STORE_DOMAIN}/admin/api/2024-10/products/${p.id}/variants.json`,
{
headers: {
"X-Shopify-Access-Token": SHOPIFY_ADMIN_ACCESS_TOKEN,
},
}
);
const variantsData = await variantsRes.json();
const totalInventory = variantsData.variants.reduce(
(sum, v) => sum + (v.inventory_quantity || 0),
0
);
// Get ALL image URLs (not just the first)
const images = p.images?.map(img => img.src) || [];
const imageUrl = images[0] || null;
// Get price from first variant
const price = variantsData.variants?.[0]?.price || "0.00";
// Strip HTML tags from description for plain text, preserve line breaks
const descriptionHtml = p.body_html || '';
const description = descriptionHtml
.replace(/
/gi, '\n') // Convert
to newlines
.replace(/<\/p>\s*
]*>/gi, '\n\n') // Convert
to double newlines .replace(/<\/?(p|div|h[1-6])[^>]*>/gi, '\n') // Block elements to newlines .replace(/<[^>]*>/g, '') // Remove remaining HTML tags .replace(/ /g, ' ') // Replace HTML entities .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/[ \t]+/g, ' ') // Collapse horizontal whitespace only .replace(/\n\s*\n\s*\n/g, '\n\n') // Max 2 consecutive newlines .trim(); return { id: p.id, handle: p.handle, title: p.title, vendor: p.vendor, productType: p.product_type, price: `$${parseFloat(price).toFixed(0)} USD`, priceRaw: parseFloat(price), imageUrl, images, // All product images description, // Plain text description with newlines descriptionHtml, // Raw HTML description shopUrl: `https://shop.aesthetic.computer/products/${p.handle}`, inventory: totalInventory, available: totalInventory > 0, tags: p.tags ? p.tags.split(", ") : [], createdAt: p.created_at, updatedAt: p.updated_at, }; }) ); // Separate available and sold products const available = products.filter((p) => p.available); const sold = products.filter((p) => !p.available); return { products, available, sold, total: products.length, lastUpdated: new Date().toISOString(), }; }