> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Bundling Validation

> Identify co-purchased Shopify products and validate bundle concepts with Mavera Focus Groups

### Scenario

You want product bundles backed by evidence. You analyze order history to find frequently co-purchased products, propose bundle concepts from top pairs, then validate with a Mavera focus group for synthetic audience feedback.

### Architecture

```mermaid theme={"dark"}
flowchart LR
A["GraphQL: orders with lineItems"] --> B["Co-occurrence analysis"] --> C["Rank + propose bundles"] --> D["POST /api/v1/focus-groups"] --> E["Bundle validation with feedback"]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, requests
  from collections import defaultdict
  from itertools import combinations

  STORE = os.environ["SHOPIFY_STORE"]
  TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
  MV = os.environ["MAVERA_API_KEY"]
  SH = f"https://{STORE}.myshopify.com/admin/api/2024-10/graphql.json"
  SH_H = {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"}
  MB = "https://app.mavera.io/api/v1"
  MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  QUERY = """query ($cursor: String) {
    orders(first: 250, after: $cursor) {
      edges { node { lineItems(first: 20) { edges { node { title originalUnitPriceSet { shopMoney { amount } } } } } } }
      pageInfo { hasNextPage endCursor }
    }
  }"""

  orders, cursor = [], None
  while True:
      resp = requests.post(SH, json={"query": QUERY, "variables": {"cursor": cursor} if cursor else {}}, headers=SH_H)
      resp.raise_for_status(); data = resp.json()["data"]["orders"]
      for e in data["edges"]:
          items = [li["node"] for li in e["node"]["lineItems"]["edges"]]
          if len(items) >= 2: orders.append(items)
      if not data["pageInfo"]["hasNextPage"]: break
      cursor = data["pageInfo"]["endCursor"]
  print(f"Analyzed {len(orders)} multi-item orders")
  counts, prices = defaultdict(int), defaultdict(list)
  for items in orders:
      titles = sorted(set(i["title"] for i in items))
      pm = {i["title"]: float(i["originalUnitPriceSet"]["shopMoney"]["amount"]) for i in items}
      for a, b in combinations(titles, 2):
          counts[(a, b)] += 1
          prices[(a, b)].append(pm.get(a, 0) + pm.get(b, 0))

  bundles = [{"products": list(p), "count": c, "bundle_price": round(sum(prices[p])/len(prices[p])*0.9, 2)}
             for p, c in sorted(counts.items(), key=lambda x: -x[1])[:10] if c >= 5]
  for b in bundles[:5]:
      print(f"  {b['products'][0]} + {b['products'][1]}: {b['count']}x → ${b['bundle_price']}")
  descs = [f"Bundle {i+1}: {b['products'][0]} + {b['products'][1]} at ${b['bundle_price']} (10% off)" for i, b in enumerate(bundles[:5])]
  fg = requests.post(f"{MB}/focus-groups", json={
      "name": "Shopify Bundle Validation",
      "questions": [f"Would you buy '{bundles[0]['products'][0]}' + '{bundles[0]['products'][1]}' at ${bundles[0]['bundle_price']}?", "Which bundle is most appealing?", "What discount makes a bundle irresistible?", "Mix-and-match or curated sets?"],
      "context": "Testing bundle concepts from purchase data.\n\n" + "\n".join(descs),
  }, headers=MH)
  fg.raise_for_status(); result = fg.json()
  print(f"\nFocus group: {result['id']}")
  for r in result.get("responses", []):
      print(f"  Q: {r['question']}\n  A: {r['answer'][:200]}\n")
  ```

  ```javascript JavaScript theme={"dark"}
  const STORE = process.env.SHOPIFY_STORE, TOKEN = process.env.SHOPIFY_ACCESS_TOKEN, MV = process.env.MAVERA_API_KEY;
  const SH = `https://${STORE}.myshopify.com/admin/api/2024-10/graphql.json`;
  const MB = "https://app.mavera.io/api/v1";
  const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  const QUERY = `query($cursor:String){orders(first:250,after:$cursor){edges{node{lineItems(first:20){edges{node{title originalUnitPriceSet{shopMoney{amount}}}}}}} pageInfo{hasNextPage endCursor}}}`;

  (async () => {
    const orders = []; let cursor = null;
    while (true) {
      const resp = await fetch(SH, { method: "POST", headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ query: QUERY, variables: cursor ? { cursor } : {} }) });
      const data = (await resp.json()).data.orders;
      for (const e of data.edges) {
        const items = e.node.lineItems.edges.map(li => li.node);
        if (items.length >= 2) orders.push(items);
      }
      if (!data.pageInfo.hasNextPage) break;
      cursor = data.pageInfo.endCursor;
    }
    console.log(`Analyzed ${orders.length} multi-item orders`);

    const counts = {}, prices = {};
    for (const items of orders) {
      const titles = [...new Set(items.map(i => i.title))].sort();
      const pm = Object.fromEntries(items.map(i => [i.title, parseFloat(i.originalUnitPriceSet.shopMoney.amount)]));
      for (let i = 0; i < titles.length; i++)
        for (let j = i + 1; j < titles.length; j++) {
          const k = `${titles[i]}|||${titles[j]}`;
          counts[k] = (counts[k] || 0) + 1;
          (prices[k] ??= []).push((pm[titles[i]] || 0) + (pm[titles[j]] || 0));
        }
    }
    const bundles = Object.entries(counts).filter(([, c]) => c >= 5).sort((a, b) => b[1] - a[1]).slice(0, 10)
      .map(([k, count]) => { const [a, b] = k.split("|||"); const avg = prices[k].reduce((s, p) => s + p, 0) / prices[k].length; return { products: [a, b], count, bundle_price: +(avg * 0.9).toFixed(2) }; });
    bundles.slice(0, 5).forEach(b => console.log(`  ${b.products[0]} + ${b.products[1]}: ${b.count}x → $${b.bundle_price}`));

    const descs = bundles.slice(0, 5).map((b, i) => `Bundle ${i + 1}: ${b.products[0]} + ${b.products[1]} at $${b.bundle_price}`);
    const fg = await fetch(`${MB}/focus-groups`, { method: "POST", headers: MH, body: JSON.stringify({
      name: "Shopify Bundle Validation",
      questions: [`Buy '${bundles[0].products[0]}' + '${bundles[0].products[1]}' at $${bundles[0].bundle_price}?`, "Most appealing bundle?", "Irresistible discount %?", "Mix-and-match or curated?"],
      context: "Testing bundles.\n\n" + descs.join("\n")
    }) });
    const result = await fg.json();
    console.log(`\nFocus group: ${result.id}`);
    (result.responses || []).forEach(r => console.log(`  Q: ${r.question}\n  A: ${r.answer.slice(0, 200)}\n`));
  })();
  ```
</CodeGroup>

### Example Output

```json theme={"dark"}
{
  "id": "fg_7d3f1a9e",
  "responses": [
    {
      "question": "Would you buy 'Merino Base Layer' + 'Trail Runner Shorts' at $112.50?",
      "answer": "Absolutely — I already buy these together. Saving $12.50 is a no-brainer."
    },
    {
      "question": "What discount makes a bundle irresistible?",
      "answer": "15% is the sweet spot. At 10% I comparison-shop, at 15% I stop thinking."
    }
  ]
}
```

### Error Handling

<AccordionGroup>
  <Accordion title="Mostly single-item orders">
    Co-occurrence needs orders with 2+ distinct products. If your store has mostly single-item orders, lower `min_count` or expand the date range.
  </Accordion>

  <Accordion title="Combinatorial explosion on large orders">
    An order with 20 unique products generates 190 pairs. Cap unique titles per order to the top 10 by quantity to keep memory manageable.
  </Accordion>
</AccordionGroup>
