<div id="wrapwrap" class="POS Orders Report ">
<header id="top" data-anchor="true" data-name="Header" class=" miller-header">
<nav data-name="Navbar" class="navbar navbar-expand-lg navbar-light o_colored_level o_cc fixed-top shadow py-0 px-1 pb-2">
<div id="top_menu_container" class="container custom_header_class d-none d-lg-block">
<div class="d-flex">
<button type="button" data-bs-toggle="collapse" data-bs-target="#top_menu_collapse" class="navbar-toggler ">
<span class="navbar-toggler-icon o_not_editable" contenteditable="false"></span></button>
<div id="top_menu_collapse" class="collapse navbar-collapse flex-column align-self-stretch align-items-stretch">
<ul id="top_menu" role="menu" class="nav navbar-nav">
<li role="presentation" class="nav-item">
<a role="menuitem" href="/corporate-gifts-1" class="nav-link bg-green ">
<span>Corporate Gifts</span>
</a>
</li>
<li role="presentation" class="nav-item">
<a role="menuitem" href="/about-us" class="nav-link bg-green ">
<span>About Us</span>
</a>
</li>
<li role="presentation" class="nav-item">
<a role="menuitem" href="/franchise" class="nav-link bg-green ">
<span>Franchise</span>
</a>
</li>
<li role="presentation" class="nav-item">
<a role="menuitem" href="/shop" class="nav-link bg-green ">
<span>Shop</span>
</a>
</li>
</ul>
</div>
<a href="/" class="navbar-brand logo m-0">
<span role="img" style="position: absolute;top: 90%;left:50%;transform: translate(-50%, -50%);" aria-label="Logo of Miller Stores" title="Miller Stores"><img src="https://www.millerstores.com/web/image/website/6/logo/Miller%20Stores?unique=b001968" class="img img-fluid" width="65" height="65" alt="Miller Stores" loading="lazy"></span>
</a>
<ul class="nav navbar-nav o_header_centered_logo justify-content-end flex-row">
<li class="dropdown search-dropdown align-self-center">
<button class="btn fa fa-search fa-2x dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" style="color:#006434;">
</button>
<ul class="dropdown-menu" style="top:45px;padding:20px;">
<li style="width:300px;">
<form method="get" class="o_searchbar_form o_wait_lazy_js s_searchbar_input w-100 o_wsale_products_searchbar_form o_not_editable" action="/shop" data-snippet="s_searchbar_input">
<div role="search" class="input-group ">
<input type="search" name="search" class="search-query form-control oe_search_box None" placeholder="Search our catalog" data-search-type="products_only" data-limit="10" data-display-image="true" data-display-description="false" data-display-extra-link="false" data-display-detail="true" data-order-by="name asc">
<button type="submit" aria-label="Search" title="Search" class="btn oe_search_button btn-primary">
<i class="oi oi-search"></i>
</button>
</div>
<input name="order" type="hidden" class="o_search_order_by">
</form>
</li>
</ul>
</li>
<li class="nav-item divider d-none"></li>
<li class="o_wsale_my_cart align-self-md-start nav-item">
<a href="javascript:void(0)" class="nav-link">
<i class="fa fa-shopping-cart fa-2x" style="color:#006434;"></i>
<sup class="my_cart_quantity badge text-bg-success" data-order-id="">0</sup>
</a>
</li>
<li class="o_wsale_my_wish nav-item">
<a href="/shop/wishlist" class="nav-link">
<i class="fa fa-2x fa-heart" style="color:#006434;"></i>
<sup class="my_wish_quantity o_animate_blink badge text-bg-success">0</sup>
</a>
</li>
<li class="nav-item signup align-self-center">
<a href="#" role="button" class="nav-link signup_open p-0" data-msq-label=" Login ">
<span style="font-weight: 600; color:#006434;" data-msq-label=" Login "> Login </span>
</a>
</li>
</ul>
</div>
</div>
<div id="mobile_header_container" class="mobile-header d-lg-none w-100">
<div class="mobile-header-top row">
<div class="header-menu col-4">
<button type="button" data-bs-toggle="collapse" data-bs-target="#top_menu_collapse" class="navbar-toggler ms-auto">
<span class="navbar-toggler-icon o_not_editable" contenteditable="false"></span></button>
<div id="top_menu_collapse" class="collapse navbar-collapse flex-column align-self-stretch align-items-stretch">
<ul id="top_menu" role="menu" class="nav navbar-nav o_menu_loading ">
<li role="presentation" class="nav-item">
<a role="menuitem" href="/corporate-gifts-1" class="nav-link ">
<span>Corporate Gifts</span>
</a>
</li>
<li role="presentation" class="nav-item">
<a role="menuitem" href="/about-us" class="nav-link ">
<span>About Us</span>
</a>
</li>
<li role="presentation" class="nav-item">
<a role="menuitem" href="/franchise" class="nav-link ">
<span>Franchise</span>
</a>
</li>
<li role="presentation" class="nav-item">
<a role="menuitem" href="/shop" class="nav-link ">
<span>Shop</span>
</a>
</li>
</ul>
</div>
</div>
<div class="header-logo col-4 d-flex">
<a href="/" class="navbar-brand logo d-block mx-auto">
<span role="img" style="position: absolute;top: 90%;left:50%;transform: translate(-50%, -50%);" aria-label="Logo of Miller Stores" title="Miller Stores"><img src="https://www.millerstores.com/web/image/website/6/logo/Miller%20Stores?unique=b001968" class="img img-fluid" width="65" height="65" alt="Miller Stores" loading="lazy"></span>
</a>
</div>
<div class="header-icons col-4 justify-content-end">
<li class=" divider d-none"></li>
<li class="o_wsale_my_cart align-self-md-start ">
<a href="javascript:void(0)" class="">
<i class="fa fa-shopping-cart fa-2x" style="color:#006434;"></i>
<sup class="my_cart_quantity badge text-bg-success" data-order-id="">0</sup>
</a>
</li>
<li class="nav-item signup align-self-center">
<a href="#" role="button" class="nav-link signup_open p-0" data-msq-label=" Login ">
<span style="font-weight: 600; color:#006434;" data-msq-label=" Login "> Login </span>
</a>
</li>
</div>
</div>
<div class="mobile-header-bottom">
<form method="get" class="o_searchbar_form o_wait_lazy_js s_searchbar_input w-100 o_wsale_products_searchbar_form o_not_editable" action="/shop" data-snippet="s_searchbar_input">
<div role="search" class="input-group ">
<input type="search" name="search" class="search-query form-control oe_search_box None" placeholder="Search our catalog" data-search-type="products_only" data-limit="10" data-display-image="true" data-display-description="false" data-display-extra-link="false" data-display-detail="true" data-order-by="name asc">
<button type="submit" aria-label="Search" title="Search" class="btn oe_search_button btn-primary">
<i class="oi oi-search"></i>
</button>
</div>
<input name="order" type="hidden" class="o_search_order_by">
</form>
</div>
</div>
</nav>
</header>
<main>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--g:#1d3c1d;--g2:#2e5a2e;--gl:#edf5ee;--gp:#f2f7f2;
--ac:#4caf50;--warn:#f59e0b;--info:#3b82f6;--red:#e53935;
--t1:#1a1a1a;--t2:#555;--t3:#888;
--bdr:#e8e8e8;--bg:#f8f9fa;--card:#fff;
--code-bg:#0f1117;--code-fg:#e2e8f0;
--sh:0 2px 12px rgba(0,0,0,.07);--r:10px;
}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--t1);line-height:1.6;}
#pr-wrap{max-width:1380px;margin:0 auto;}
.pr-topbar{background:var(--g);color:#fff;padding:13px 28px;
display:flex;align-items:center;gap:14px;font-size:14px;font-weight:600;
box-shadow:0 2px 8px rgba(0,0,0,.18);}
.pr-topbar-title{flex:1;font-size:15px;}
.pr-ver{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);
padding:2px 10px;border-radius:12px;font-size:11px;font-weight:700;}
.pr-layout{display:grid;grid-template-columns:264px 1fr;}
.pr-nav{position:sticky;top:0;height:100vh;overflow-y:auto;
background:var(--card);border-right:1px solid var(--bdr);padding:20px 0;
scrollbar-width:thin;}
.pr-nav-label{font-size:10px;font-weight:800;color:var(--t3);text-transform:uppercase;
letter-spacing:.09em;padding:0 18px 10px;}
.pr-nav ul{list-style:none;padding:0;margin:0;}
.pr-nav li{margin:0;}
.pr-nav a{display:block;padding:6px 18px;font-size:12.5px;color:var(--t2);
text-decoration:none;border-left:3px solid transparent;transition:all .15s;line-height:1.4;}
.pr-nav a:hover{color:var(--g);background:var(--gp);}
.pr-nav a.active{color:var(--g);background:var(--gl);border-left-color:var(--g);font-weight:700;}
.pr-nav ul ul a{padding-left:32px;font-size:11.5px;color:var(--t3);}
.pr-nav ul ul a:hover,.pr-nav ul ul a.active{color:var(--g);}
.pr-nav-sep{height:1px;background:var(--bdr);margin:8px 18px;}
.pr-content{padding:44px 52px 100px;max-width:880px;}
.pr-part{margin-bottom:72px;}
.pr-part-hdr{display:flex;align-items:center;gap:12px;margin-bottom:28px;
padding-bottom:14px;border-bottom:2px solid var(--g);}
.pr-part-num{background:var(--g);color:#fff;border-radius:8px;
padding:4px 12px;font-size:11px;font-weight:800;letter-spacing:.04em;text-transform:uppercase;}
.pr-part-title{font-size:20px;font-weight:800;color:var(--g);letter-spacing:-.3px;}
h2.pr-h2{font-size:15px;font-weight:700;color:var(--t1);margin:32px 0 10px;
padding-bottom:6px;border-bottom:1px solid var(--bdr);}
h3.pr-h3{font-size:13.5px;font-weight:700;color:var(--g2);margin:20px 0 8px;}
p.pr-p{font-size:13.5px;color:var(--t2);margin-bottom:10px;line-height:1.65;}
.pr-codewrap{margin:12px 0 20px;border-radius:var(--r);overflow:hidden;
box-shadow:0 2px 12px rgba(0,0,0,.15);}
.pr-codehdr{background:#1a1a2e;padding:8px 14px;display:flex;align-items:center;gap:8px;}
.pr-lang{font-size:11px;font-weight:700;color:#4ade80;}
.pr-codetitle{font-size:11px;color:#64748b;flex:1;}
pre.pr-code{background:var(--code-bg);color:var(--code-fg);padding:18px 20px;
font-size:12px;line-height:1.65;overflow-x:auto;margin:0;
font-family:'Fira Code','Cascadia Code','Consolas',monospace;white-space:pre;}
pre.pr-tree{background:#f4f6f8;color:var(--t1);border:1px solid var(--bdr);
border-left:3px solid var(--g);border-radius:var(--r);padding:14px 18px;
font-size:12.5px;line-height:1.8;font-family:'Consolas',monospace;
margin:10px 0 20px;overflow-x:auto;}
.pr-info{background:var(--gl);border-left:4px solid var(--g);
border-radius:0 var(--r) var(--r) 0;padding:12px 16px;margin:12px 0 16px;
font-size:13px;line-height:1.65;color:var(--t1);}
.pr-formula{background:#fffbeb;border-left:4px solid var(--warn);
border-radius:0 var(--r) var(--r) 0;padding:12px 16px;margin:12px 0 16px;
font-size:13px;font-family:monospace;line-height:1.9;color:#78350f;}
.pr-formula strong{color:#92400e;}
.pr-note{background:#eff6ff;border-left:4px solid var(--info);
border-radius:0 var(--r) var(--r) 0;padding:12px 16px;margin:12px 0 16px;
font-size:13px;line-height:1.65;color:#1e40af;}
.pr-tbl-wrap{overflow-x:auto;margin:12px 0 20px;border-radius:var(--r);
box-shadow:var(--sh);border:1px solid var(--bdr);}
table.pr-tbl{width:100%;border-collapse:collapse;font-size:12.5px;}
table.pr-tbl th{background:var(--g);color:#fff;padding:10px 14px;
text-align:left;font-size:11.5px;font-weight:700;letter-spacing:.03em;}
table.pr-tbl td{padding:9px 14px;border-bottom:1px solid var(--bdr);vertical-align:top;}
table.pr-tbl tr:last-child td{border-bottom:none;}
table.pr-tbl tr:hover td{background:var(--gp);}
.pr-pill{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:700;}
.pr-pill.py{background:#dbeafe;color:#1d4ed8;}
.pr-pill.sq{background:#fef3c7;color:#92400e;}
.pr-pill.st{background:#dcfce7;color:#166534;}
.pr-pill.ro{background:#f3e8ff;color:#6b21a8;}
.pr-kpis{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;margin:16px 0 24px;}
.pr-kpi{background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);
padding:16px;box-shadow:var(--sh);border-top:3px solid var(--g);}
.pr-kpi-val{font-size:22px;font-weight:800;color:var(--g);line-height:1;}
.pr-kpi-label{font-size:11.5px;color:var(--t3);margin-top:4px;font-weight:600;}
.pr-kpi-sub{font-size:11px;color:var(--t3);margin-top:2px;}
.pr-stored{font-size:11px;color:var(--t3);margin-top:8px;margin-bottom:20px;}
.pr-stored code{background:var(--gl);color:var(--g);padding:2px 6px;border-radius:4px;font-size:11px;}
.pr-phase{background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);
margin-bottom:16px;overflow:hidden;box-shadow:var(--sh);}
.pr-phase-hdr{background:var(--gl);padding:12px 18px;
display:flex;align-items:center;gap:10px;}
.pr-phase-num{background:var(--g);color:#fff;border-radius:50%;
width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;
font-size:12px;font-weight:800;flex-shrink:0;}
.pr-phase-title{font-size:14px;font-weight:700;color:var(--g);}
.pr-phase-sub{font-size:12px;color:var(--t3);}
.pr-phase-body{padding:14px 18px;}
.pr-checklist{list-style:none;padding:0;}
.pr-checklist li{padding:5px 0 5px 26px;position:relative;font-size:13px;color:var(--t2);}
.pr-checklist li::before{content:'☐';position:absolute;left:0;color:var(--g);font-size:14px;}
.pr-report-card{background:var(--card);border:1px solid var(--bdr);border-radius:var(--r);
padding:18px;margin-bottom:14px;box-shadow:var(--sh);}
.pr-report-title{font-size:14px;font-weight:700;color:var(--g);margin-bottom:6px;
display:flex;align-items:center;gap:8px;}
.pr-report-desc{font-size:13px;color:var(--t2);line-height:1.55;}
.pr-tag{display:inline-block;padding:2px 8px;border-radius:12px;
font-size:11px;font-weight:700;background:var(--gl);color:var(--g2);}
.pr-menu-btn{display:none;}
@media(max-width:860px){
.pr-layout{grid-template-columns:1fr;}
.pr-nav{position:fixed;left:-270px;top:0;height:100vh;z-index:999;
transition:left .28s cubic-bezier(.4,0,.2,1);
box-shadow:4px 0 24px rgba(0,0,0,.12);}
.pr-nav.open{left:0;}
.pr-content{padding:24px 18px 80px;}
.pr-kpis{grid-template-columns:repeat(2,1fr);}
.pr-menu-btn{display:flex;position:fixed;bottom:20px;right:20px;z-index:1000;
background:var(--g);color:#fff;border:none;border-radius:50%;
width:50px;height:50px;align-items:center;justify-content:center;
font-size:22px;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.25);}
.pr-overlay{display:block !important;}
}
.pr-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:998;}
</style>
<div id="pr-wrap">
<div class="pr-topbar">
<span style="font-size:22px">📋</span>
<div class="pr-topbar-title">Miller Stores Odoo — POS Orders Report</div>
<span class="pr-ver">v1.0</span>
</div>
<div class="pr-layout">
<nav class="pr-nav" id="pr-nav">
<div class="pr-nav-label">Table of Contents</div>
<ul>
<li><a href="#p1">Part 1: Data Schema</a>
<ul>
<li><a href="#p1-1">1.1 Core POS Models</a></li>
<li><a href="#p1-2">1.2 pos.order Fields</a></li>
<li><a href="#p1-3">1.3 pos.order.line Fields</a></li>
<li><a href="#p1-4">1.4 pos.session Fields</a></li>
<li><a href="#p1-5">1.5 pos.payment Fields</a></li>
</ul>
</li>
<li><div class="pr-nav-sep"></div></li>
<li><a href="#p2">Part 2: Core Calculations</a>
<ul>
<li><a href="#p2-1">2.1 Daily Revenue</a></li>
<li><a href="#p2-2">2.2 Average Order Value</a></li>
<li><a href="#p2-3">2.3 Product Sales Breakdown</a></li>
<li><a href="#p2-4">2.4 Category Sales</a></li>
<li><a href="#p2-5">2.5 Cashier Performance</a></li>
<li><a href="#p2-6">2.6 Payment Method Split</a></li>
<li><a href="#p2-7">2.7 Hourly Sales Pattern</a></li>
<li><a href="#p2-8">2.8 Discount Analysis</a></li>
<li><a href="#p2-9">2.9 Refund Analysis</a></li>
</ul>
</li>
<li><div class="pr-nav-sep"></div></li>
<li><a href="#p3">Part 3: Report Types</a>
<ul>
<li><a href="#p3-1">3.1 Daily Summary</a></li>
<li><a href="#p3-2">3.2 Product Report</a></li>
<li><a href="#p3-3">3.3 Session Report</a></li>
<li><a href="#p3-4">3.4 Cashier Report</a></li>
<li><a href="#p3-5">3.5 Payment Report</a></li>
</ul>
</li>
<li><div class="pr-nav-sep"></div></li>
<li><a href="#p4">Part 4: Implementation</a></li>
<li><a href="#p5">Part 5: SQL Queries</a></li>
<li><a href="#p6">Part 6: Module Structure</a></li>
<li><a href="#p7">Part 7: Dashboard Example</a></li>
</ul>
</nav>
<main class="pr-content">
<section class="pr-part" id="p1">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 1</span>
<span class="pr-part-title">Data Schema</span>
</div>
<h2 class="pr-h2" id="p1-1">1.1 Core POS Models</h2>
<pre class="pr-tree">Point of Sale — Core Models
├── pos.order Main order record (1 per customer transaction)
├── pos.order.line Individual product lines within an order
├── pos.session Cashier session (open/close of terminal)
├── pos.config POS terminal configuration
├── pos.payment Payment records per order
├── pos.payment.method Payment method master (Cash, Card, UPI etc.)
└── pos.category POS-specific product categories</pre>
<h2 class="pr-h2" id="p1-2">1.2 pos.order — Key Fields</h2>
<div class="pr-tbl-wrap">
<table class="pr-tbl">
<thead><tr><th>Field</th><th>Type</th><th>Description</th><th>Use in Report</th></tr></thead>
<tbody>
<tr><td><code>id</code></td><td>Integer</td><td>Unique order ID</td><td>Row count, joins</td></tr>
<tr><td><code>name</code></td><td>Char</td><td>Order reference (POS/001/0001)</td><td>Display</td></tr>
<tr><td><code>date_order</code></td><td>Datetime</td><td>Order timestamp</td><td>Daily/hourly grouping</td></tr>
<tr><td><code>partner_id</code></td><td>Many2one</td><td>Customer (optional in POS)</td><td>Customer orders</td></tr>
<tr><td><code>session_id</code></td><td>Many2one</td><td>POS session</td><td>Session grouping</td></tr>
<tr><td><code>config_id</code></td><td>Many2one</td><td>POS terminal</td><td>Terminal filter</td></tr>
<tr><td><code>user_id</code></td><td>Many2one</td><td>Cashier</td><td>Cashier report</td></tr>
<tr><td><code>amount_total</code></td><td>Float</td><td>Total incl. tax</td><td>Revenue</td></tr>
<tr><td><code>amount_tax</code></td><td>Float</td><td>Tax amount</td><td>Tax report</td></tr>
<tr><td><code>amount_paid</code></td><td>Float</td><td>Amount received</td><td>Reconciliation</td></tr>
<tr><td><code>amount_return</code></td><td>Float</td><td>Change given back</td><td>Cash handling</td></tr>
<tr><td><code>lines</code></td><td>One2many</td><td>Order lines</td><td>Product drill-down</td></tr>
<tr><td><code>payment_ids</code></td><td>One2many</td><td>Payments</td><td>Payment method split</td></tr>
<tr><td><code>state</code></td><td>Selection</td><td>draft/paid/done/invoiced/cancel</td><td>Filter valid orders</td></tr>
<tr><td><code>nb_print</code></td><td>Integer</td><td>Times receipt printed</td><td>Operations</td></tr>
</tbody>
</table>
</div>
<h2 class="pr-h2" id="p1-3">1.3 pos.order.line — Key Fields</h2>
<div class="pr-tbl-wrap">
<table class="pr-tbl">
<thead><tr><th>Field</th><th>Type</th><th>Description</th><th>Use in Report</th></tr></thead>
<tbody>
<tr><td><code>order_id</code></td><td>Many2one</td><td>Parent order</td><td>Join to orders</td></tr>
<tr><td><code>product_id</code></td><td>Many2one</td><td>Product sold</td><td>Product breakdown</td></tr>
<tr><td><code>product_uom_id</code></td><td>Many2one</td><td>Unit of measure</td><td>Qty normalization</td></tr>
<tr><td><code>qty</code></td><td>Float</td><td>Quantity sold</td><td>Units sold</td></tr>
<tr><td><code>price_unit</code></td><td>Float</td><td>Unit price</td><td>Price analysis</td></tr>
<tr><td><code>discount</code></td><td>Float</td><td>Discount % applied</td><td>Discount report</td></tr>
<tr><td><code>price_subtotal</code></td><td>Float</td><td>Subtotal excl. tax</td><td>Net revenue</td></tr>
<tr><td><code>price_subtotal_incl</code></td><td>Float</td><td>Subtotal incl. tax</td><td>Gross revenue</td></tr>
<tr><td><code>tax_ids</code></td><td>Many2many</td><td>Applied taxes</td><td>Tax breakdown</td></tr>
</tbody>
</table>
</div>
<h2 class="pr-h2" id="p1-4">1.4 pos.session — Key Fields</h2>
<div class="pr-tbl-wrap">
<table class="pr-tbl">
<thead><tr><th>Field</th><th>Type</th><th>Description</th><th>Use in Report</th></tr></thead>
<tbody>
<tr><td><code>name</code></td><td>Char</td><td>Session ref (Opening 0001)</td><td>Display</td></tr>
<tr><td><code>config_id</code></td><td>Many2one</td><td>POS terminal config</td><td>Terminal grouping</td></tr>
<tr><td><code>user_id</code></td><td>Many2one</td><td>Session opener (cashier)</td><td>Cashier attribution</td></tr>
<tr><td><code>start_at</code></td><td>Datetime</td><td>Session opened</td><td>Session duration</td></tr>
<tr><td><code>stop_at</code></td><td>Datetime</td><td>Session closed</td><td>Session duration</td></tr>
<tr><td><code>state</code></td><td>Selection</td><td>new/opening_control/opened/closing_control/closed</td><td>Active sessions</td></tr>
<tr><td><code>order_ids</code></td><td>One2many</td><td>All orders in session</td><td>Session totals</td></tr>
<tr><td><code>cash_register_balance_start</code></td><td>Float</td><td>Opening float</td><td>Cash reconciliation</td></tr>
<tr><td><code>cash_register_balance_end_real</code></td><td>Float</td><td>Closing count</td><td>Cash variance</td></tr>
</tbody>
</table>
</div>
<h2 class="pr-h2" id="p1-5">1.5 pos.payment — Key Fields</h2>
<div class="pr-tbl-wrap">
<table class="pr-tbl">
<thead><tr><th>Field</th><th>Type</th><th>Description</th><th>Use in Report</th></tr></thead>
<tbody>
<tr><td><code>pos_order_id</code></td><td>Many2one</td><td>Parent order</td><td>Join</td></tr>
<tr><td><code>payment_method_id</code></td><td>Many2one</td><td>Cash / Card / UPI</td><td>Payment split</td></tr>
<tr><td><code>amount</code></td><td>Float</td><td>Amount paid by this method</td><td>Method totals</td></tr>
<tr><td><code>session_id</code></td><td>Many2one</td><td>POS session</td><td>Session totals</td></tr>
</tbody>
</table>
</div>
</section>
<section class="pr-part" id="p2">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 2</span>
<span class="pr-part-title">Core Calculations</span>
</div>
<h2 class="pr-h2" id="p2-1">2.1 Daily Revenue</h2>
<div class="pr-info"><strong>What:</strong> Total revenue (gross + net), order count, and unique customers per day.<br><strong>Filter:</strong> Only <code>state IN ('paid','done','invoiced')</code> — excludes drafts and cancellations.</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">daily_revenue_report</span></div>
<pre class="pr-code">def get_daily_revenue(self, date_from, date_to):
orders = self.env['pos.order'].search([
('date_order', '>=', date_from),
('date_order', '<=', date_to),
('state', 'in', ['paid', 'done', 'invoiced']),
])
# Group by date
by_date = {}
for order in orders:
day = order.date_order.date().isoformat()
if day not in by_date:
by_date[day] = {
'date': day,
'order_count': 0,
'gross_revenue': 0.0,
'tax_amount': 0.0,
'net_revenue': 0.0,
'customers': set(),
}
d = by_date[day]
d['order_count'] += 1
d['gross_revenue'] += order.amount_total
d['tax_amount'] += order.amount_tax
d['net_revenue'] += (order.amount_total - order.amount_tax)
if order.partner_id:
d['customers'].add(order.partner_id.id)
result = sorted(by_date.values(), key=lambda x: x['date'])
for r in result:
r['unique_customers'] = len(r.pop('customers'))
return result</pre>
</div>
<div class="pr-stored">Stored in: <code>pos.order</code> (computed on demand, not cached)</div>
<h2 class="pr-h2" id="p2-2">2.2 Average Order Value (AOV)</h2>
<div class="pr-formula">
<strong>AOV</strong> = Total Revenue ÷ Total Orders<br>
<strong>AOV by Cashier</strong> = Cashier Revenue ÷ Cashier Order Count<br>
<strong>AOV by Hour</strong> = Hour Revenue ÷ Hour Order Count
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
DATE(date_order AT TIME ZONE 'Asia/Kolkata') AS order_date,
COUNT(*) AS order_count,
ROUND(SUM(amount_total)::numeric, 2) AS gross_revenue,
ROUND(SUM(amount_tax)::numeric, 2) AS tax_collected,
ROUND(SUM(amount_total - amount_tax)::numeric, 2) AS net_revenue,
ROUND(AVG(amount_total)::numeric, 2) AS aov,
ROUND(MIN(amount_total)::numeric, 2) AS min_order,
ROUND(MAX(amount_total)::numeric, 2) AS max_order
FROM pos_order
WHERE state IN ('paid','done','invoiced')
AND date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY DATE(date_order AT TIME ZONE 'Asia/Kolkata')
ORDER BY order_date;</pre>
</div>
<h2 class="pr-h2" id="p2-3">2.3 Product Sales Breakdown</h2>
<div class="pr-info"><strong>What:</strong> Qty sold, gross revenue, and % share for each product. Supports filtering by date range, session, or cashier.<br><strong>When:</strong> On demand via report endpoint.</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">product_sales_report</span></div>
<pre class="pr-code">def get_product_sales(self, date_from, date_to, top_n=None):
lines = self.env['pos.order.line'].search([
('order_id.date_order', '>=', date_from),
('order_id.date_order', '<=', date_to),
('order_id.state', 'in', ['paid','done','invoiced']),
])
products = {}
total_revenue = 0.0
for line in lines:
pid = line.product_id.id
name = line.product_id.name
if pid not in products:
products[pid] = {
'product': name,
'category': line.product_id.categ_id.name or 'Uncategorised',
'qty': 0.0,
'revenue': 0.0,
'discount_total': 0.0,
'orders': set(),
}
p = products[pid]
p['qty'] += line.qty
p['revenue'] += line.price_subtotal_incl
if line.discount:
p['discount_total'] += (
line.price_unit * line.qty * line.discount / 100
)
p['orders'].add(line.order_id.id)
total_revenue += line.price_subtotal_incl
result = []
for pid, p in products.items():
result.append({
'product': p['product'],
'category': p['category'],
'qty': round(p['qty'], 2),
'revenue': round(p['revenue'], 2),
'rev_share': round(p['revenue'] / total_revenue * 100, 1) if total_revenue else 0,
'discount': round(p['discount_total'], 2),
'orders': len(p['orders']),
})
result.sort(key=lambda x: x['revenue'], reverse=True)
return result[:top_n] if top_n else result</pre>
</div>
<div class="pr-stored">Source: <code>pos.order.line</code> joined with <code>pos.order</code></div>
<h2 class="pr-h2" id="p2-4">2.4 Category Sales</h2>
<div class="pr-formula">
<strong>Category Revenue %</strong> = Category Revenue ÷ Total Revenue × 100
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
COALESCE(pt.categ_id::text, 'Uncategorised') AS category_id,
pc.name AS category,
ROUND(SUM(pol.qty)::numeric, 2) AS total_qty,
ROUND(SUM(pol.price_subtotal_incl)::numeric, 2) AS gross_revenue,
ROUND(
100.0 * SUM(pol.price_subtotal_incl) /
SUM(SUM(pol.price_subtotal_incl)) OVER (), 1
) AS revenue_share_pct,
COUNT(DISTINCT pol.order_id) AS order_count
FROM pos_order_line pol
JOIN pos_order po ON po.id = pol.order_id
JOIN product_product pp ON pp.id = pol.product_id
JOIN product_template pt ON pt.id = pp.product_tmpl_id
LEFT JOIN product_category pc ON pc.id = pt.categ_id
WHERE po.state IN ('paid','done','invoiced')
AND po.date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY pt.categ_id, pc.name
ORDER BY gross_revenue DESC;</pre>
</div>
<h2 class="pr-h2" id="p2-5">2.5 Cashier Performance</h2>
<div class="pr-info"><strong>What:</strong> Orders handled, revenue generated, AOV, and avg session duration per cashier.<br><strong>Key insight:</strong> High order count + low AOV may indicate cashiers upselling less.</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">cashier_performance_report</span></div>
<pre class="pr-code">def get_cashier_performance(self, date_from, date_to):
orders = self.env['pos.order'].search([
('date_order', '>=', date_from),
('date_order', '<=', date_to),
('state', 'in', ['paid','done','invoiced']),
])
cashiers = {}
for order in orders:
uid = order.user_id.id
name = order.user_id.name or 'Unknown'
if uid not in cashiers:
cashiers[uid] = {
'cashier': name,
'order_count': 0,
'revenue': 0.0,
'items_sold': 0.0,
'discounts': 0.0,
}
c = cashiers[uid]
c['order_count'] += 1
c['revenue'] += order.amount_total
for line in order.lines:
c['items_sold'] += line.qty
if line.discount:
c['discounts'] += (
line.price_unit * line.qty * line.discount / 100
)
result = []
for uid, c in cashiers.items():
result.append({
'cashier': c['cashier'],
'orders': c['order_count'],
'revenue': round(c['revenue'], 2),
'aov': round(c['revenue'] / c['order_count'], 2) if c['order_count'] else 0,
'items_sold': round(c['items_sold'], 2),
'avg_items': round(c['items_sold'] / c['order_count'], 2) if c['order_count'] else 0,
'discounts': round(c['discounts'], 2),
})
result.sort(key=lambda x: x['revenue'], reverse=True)
return result</pre>
</div>
<div class="pr-stored">Source: <code>pos.order.user_id</code>, <code>pos.order.lines</code></div>
<h2 class="pr-h2" id="p2-6">2.6 Payment Method Split</h2>
<div class="pr-formula">
<strong>Method Share %</strong> = Method Amount ÷ Total Collected × 100
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
ppm.name AS payment_method,
COUNT(DISTINCT pp.pos_order_id) AS order_count,
ROUND(SUM(pp.amount)::numeric, 2) AS total_collected,
ROUND(
100.0 * SUM(pp.amount) /
SUM(SUM(pp.amount)) OVER (), 1
) AS share_pct
FROM pos_payment pp
JOIN pos_payment_method ppm ON ppm.id = pp.payment_method_id
JOIN pos_order po ON po.id = pp.pos_order_id
WHERE po.state IN ('paid','done','invoiced')
AND po.date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY ppm.name
ORDER BY total_collected DESC;</pre>
</div>
<h2 class="pr-h2" id="p2-7">2.7 Hourly Sales Pattern</h2>
<div class="pr-info"><strong>What:</strong> Revenue and order count grouped by hour of day (IST). Reveals peak hours for staffing and promotion planning.</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
EXTRACT(HOUR FROM date_order AT TIME ZONE 'Asia/Kolkata') AS hour_of_day,
COUNT(*) AS order_count,
ROUND(SUM(amount_total)::numeric, 2) AS revenue,
ROUND(AVG(amount_total)::numeric, 2) AS aov
FROM pos_order
WHERE state IN ('paid','done','invoiced')
AND date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY hour_of_day
ORDER BY hour_of_day;</pre>
</div>
<h2 class="pr-h2" id="p2-8">2.8 Discount Analysis</h2>
<div class="pr-formula">
<strong>Discount Rate %</strong> = Total Discount Given ÷ Gross (pre-discount) Revenue × 100<br>
<strong>Gross Pre-discount</strong> = price_unit × qty (before discount applied)
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
ROUND(SUM(pol.price_unit * pol.qty)::numeric, 2) AS gross_pre_discount,
ROUND(SUM(pol.price_subtotal_incl)::numeric, 2) AS net_revenue,
ROUND(SUM(
pol.price_unit * pol.qty * pol.discount / 100
)::numeric, 2) AS total_discount,
ROUND(
100.0 * SUM(pol.price_unit * pol.qty * pol.discount / 100) /
NULLIF(SUM(pol.price_unit * pol.qty), 0), 2
) AS discount_rate_pct,
COUNT(DISTINCT CASE WHEN pol.discount > 0 THEN pol.order_id END) AS discounted_orders,
COUNT(DISTINCT pol.order_id) AS total_orders
FROM pos_order_line pol
JOIN pos_order po ON po.id = pol.order_id
WHERE po.state IN ('paid','done','invoiced')
AND po.date_order BETWEEN %(date_from)s AND %(date_to)s;</pre>
</div>
<h2 class="pr-h2" id="p2-9">2.9 Refund Analysis</h2>
<div class="pr-info"><strong>What:</strong> Refunded orders have negative <code>amount_total</code>. Tracking refunds by product and cashier identifies service or quality issues.</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">refund_summary</span></div>
<pre class="pr-code">def get_refund_summary(self, date_from, date_to):
# Refunds are pos.order records with amount_total < 0
refunds = self.env['pos.order'].search([
('date_order', '>=', date_from),
('date_order', '<=', date_to),
('amount_total', '<', 0),
('state', 'in', ['paid','done','invoiced']),
])
total_refunded = abs(sum(r.amount_total for r in refunds))
refund_count = len(refunds)
all_orders_count = self.env['pos.order'].search_count([
('date_order', '>=', date_from),
('date_order', '<=', date_to),
('state', 'in', ['paid','done','invoiced']),
('amount_total', '>=', 0),
])
return {
'refund_count': refund_count,
'total_refunded': round(total_refunded, 2),
'refund_rate_pct': round(refund_count / all_orders_count * 100, 2) if all_orders_count else 0,
'avg_refund_value': round(total_refunded / refund_count, 2) if refund_count else 0,
}</pre>
</div>
</section>
<section class="pr-part" id="p3">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 3</span>
<span class="pr-part-title">Report Types</span>
</div>
<div class="pr-kpis">
<div class="pr-kpi"><div class="pr-kpi-val">9</div><div class="pr-kpi-label">Report Types</div><div class="pr-kpi-sub">Covering all POS dimensions</div></div>
<div class="pr-kpi"><div class="pr-kpi-val">5</div><div class="pr-kpi-label">Core Reports</div><div class="pr-kpi-sub">Daily, Product, Session, Cashier, Payment</div></div>
<div class="pr-kpi"><div class="pr-kpi-val">IST</div><div class="pr-kpi-label">Timezone</div><div class="pr-kpi-sub">All timestamps in Asia/Kolkata</div></div>
<div class="pr-kpi"><div class="pr-kpi-val">Live</div><div class="pr-kpi-label">Refresh</div><div class="pr-kpi-sub">On-demand, no cron required</div></div>
</div>
<h2 class="pr-h2" id="p3-1">3.1 Daily Summary Report</h2>
<div class="pr-report-card">
<div class="pr-report-title"><span class="pr-tag">Daily</span> End-of-Day Sales Summary</div>
<div class="pr-report-desc">One row per day. Shows gross revenue, net revenue, tax collected, order count, AOV, unique customers, and comparison vs previous day. Used for daily close-out and owner review.</div>
</div>
<div class="pr-tbl-wrap">
<table class="pr-tbl">
<thead><tr><th>Column</th><th>Formula</th><th>Source</th></tr></thead>
<tbody>
<tr><td>Date</td><td>DATE(date_order AT TIME ZONE 'IST')</td><td>pos.order</td></tr>
<tr><td>Orders</td><td>COUNT(*)</td><td>pos.order</td></tr>
<tr><td>Gross Revenue</td><td>SUM(amount_total)</td><td>pos.order</td></tr>
<tr><td>Tax Collected</td><td>SUM(amount_tax)</td><td>pos.order</td></tr>
<tr><td>Net Revenue</td><td>SUM(amount_total − amount_tax)</td><td>pos.order</td></tr>
<tr><td>AOV</td><td>AVG(amount_total)</td><td>pos.order</td></tr>
<tr><td>Items Sold</td><td>SUM(pol.qty)</td><td>pos.order.line</td></tr>
<tr><td>Unique Customers</td><td>COUNT(DISTINCT partner_id)</td><td>pos.order</td></tr>
<tr><td>Cash Collected</td><td>SUM(pp.amount) WHERE method=Cash</td><td>pos.payment</td></tr>
<tr><td>Card/UPI Collected</td><td>SUM(pp.amount) WHERE method!=Cash</td><td>pos.payment</td></tr>
</tbody>
</table>
</div>
<h2 class="pr-h2" id="p3-2">3.2 Product Sales Report</h2>
<div class="pr-report-card">
<div class="pr-report-title"><span class="pr-tag">Product</span> Top Products by Revenue & Quantity</div>
<div class="pr-report-desc">One row per product. Shows qty sold, gross revenue, revenue share %, discount given, and number of orders containing the product. Sortable by any column. Filterable by category.</div>
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
pt.name AS product,
pc.name AS category,
ROUND(SUM(pol.qty)::numeric, 2) AS qty_sold,
ROUND(SUM(pol.price_subtotal_incl)::numeric, 2) AS gross_revenue,
ROUND(
100.0 * SUM(pol.price_subtotal_incl) /
SUM(SUM(pol.price_subtotal_incl)) OVER (), 1
) AS rev_share_pct,
ROUND(AVG(pol.discount)::numeric, 2) AS avg_discount_pct,
ROUND(SUM(
pol.price_unit * pol.qty * pol.discount / 100
)::numeric, 2) AS discount_given,
COUNT(DISTINCT pol.order_id) AS order_count
FROM pos_order_line pol
JOIN pos_order po ON po.id = pol.order_id
JOIN product_product pp ON pp.id = pol.product_id
JOIN product_template pt ON pt.id = pp.product_tmpl_id
LEFT JOIN product_category pc ON pc.id = pt.categ_id
WHERE po.state IN ('paid','done','invoiced')
AND po.date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY pt.name, pc.name
ORDER BY gross_revenue DESC;</pre>
</div>
<h2 class="pr-h2" id="p3-3">3.3 Session Report</h2>
<div class="pr-report-card">
<div class="pr-report-title"><span class="pr-tag">Session</span> Per-Session Revenue & Cash Reconciliation</div>
<div class="pr-report-desc">One row per POS session. Shows opening float, closing count, expected cash, variance, total revenue, and order breakdown. Used for end-of-session cash reconciliation.</div>
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">session_report</span></div>
<pre class="pr-code">def get_session_report(self, date_from, date_to):
sessions = self.env['pos.session'].search([
('start_at', '>=', date_from),
('start_at', '<=', date_to),
('state', '=', 'closed'),
])
result = []
for s in sessions:
orders = s.order_ids.filtered(
lambda o: o.state in ['paid','done','invoiced']
)
cash_orders = sum(
p.amount for o in orders
for p in o.payment_ids
if p.payment_method_id.is_cash_count
)
expected = s.cash_register_balance_start + cash_orders
variance = (s.cash_register_balance_end_real or 0) - expected
duration = (s.stop_at - s.start_at).total_seconds() / 3600 if s.stop_at else 0
result.append({
'session': s.name,
'cashier': s.user_id.name,
'start': s.start_at.strftime('%Y-%m-%d %H:%M'),
'stop': s.stop_at.strftime('%Y-%m-%d %H:%M') if s.stop_at else '-',
'duration_hrs': round(duration, 2),
'order_count': len(orders),
'revenue': round(sum(o.amount_total for o in orders), 2),
'opening_float': s.cash_register_balance_start,
'expected_cash': round(expected, 2),
'counted_cash': s.cash_register_balance_end_real or 0,
'cash_variance': round(variance, 2),
})
return result</pre>
</div>
<h2 class="pr-h2" id="p3-4">3.4 Cashier Performance Report</h2>
<div class="pr-report-card">
<div class="pr-report-title"><span class="pr-tag">Cashier</span> Per-Cashier Sales KPIs</div>
<div class="pr-report-desc">One row per cashier. Shows orders handled, revenue, AOV, items sold per order (basket size), and discount given. Helps identify top performers and training needs.</div>
</div>
<div class="pr-tbl-wrap">
<table class="pr-tbl">
<thead><tr><th>KPI</th><th>Formula</th><th>Insight</th></tr></thead>
<tbody>
<tr><td>Orders</td><td>COUNT(*) per cashier</td><td>Throughput</td></tr>
<tr><td>Revenue</td><td>SUM(amount_total) per cashier</td><td>Sales contribution</td></tr>
<tr><td>AOV</td><td>Revenue ÷ Orders</td><td>Upsell effectiveness</td></tr>
<tr><td>Avg Basket</td><td>SUM(qty) ÷ Orders</td><td>Cross-sell effectiveness</td></tr>
<tr><td>Discount Given</td><td>SUM(discount_value)</td><td>Discount control</td></tr>
<tr><td>Refunds</td><td>COUNT(amount_total < 0)</td><td>Error rate</td></tr>
<tr><td>Sessions</td><td>COUNT(DISTINCT session_id)</td><td>Working days</td></tr>
</tbody>
</table>
</div>
<h2 class="pr-h2" id="p3-5">3.5 Payment Method Report</h2>
<div class="pr-report-card">
<div class="pr-report-title"><span class="pr-tag">Payment</span> Cash vs Card vs UPI Breakdown</div>
<div class="pr-report-desc">Splits all collected revenue by payment method. Shows order count, total collected, and percentage share per method. Supports daily and session-level grouping.</div>
</div>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">payment_method_report</span></div>
<pre class="pr-code">def get_payment_breakdown(self, date_from, date_to):
payments = self.env['pos.payment'].search([
('session_id.start_at', '>=', date_from),
('session_id.start_at', '<=', date_to),
('pos_order_id.state', 'in', ['paid','done','invoiced']),
])
methods = {}
total = 0.0
for p in payments:
m = p.payment_method_id.name
methods.setdefault(m, {'amount': 0.0, 'orders': set()})
methods[m]['amount'] += p.amount
methods[m]['orders'].add(p.pos_order_id.id)
total += p.amount
return [{
'method': m,
'amount': round(v['amount'], 2),
'orders': len(v['orders']),
'share_pct': round(v['amount'] / total * 100, 1) if total else 0,
} for m, v in sorted(methods.items(), key=lambda x: -x[1]['amount'])]</pre>
</div>
</section>
<section class="pr-part" id="p4">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 4</span>
<span class="pr-part-title">Implementation Roadmap</span>
</div>
<div class="pr-phase">
<div class="pr-phase-hdr"><span class="pr-phase-num">1</span><span class="pr-phase-title">Phase 1 — Model Setup</span><span class="pr-phase-sub">Week 1</span></div>
<div class="pr-phase-body">
<ul class="pr-checklist">
<li>Create <code>miller.pos.report</code> model (transient / wizard)</li>
<li>Add date_from / date_to / session filter fields</li>
<li>Add report_type selection field (daily / product / cashier / session / payment)</li>
<li>Register report wizard in POS Orders menu</li>
</ul>
</div>
</div>
<div class="pr-phase">
<div class="pr-phase-hdr"><span class="pr-phase-num">2</span><span class="pr-phase-title">Phase 2 — Calculation Methods</span><span class="pr-phase-sub">Week 2</span></div>
<div class="pr-phase-body">
<ul class="pr-checklist">
<li>Implement <code>get_daily_revenue()</code></li>
<li>Implement <code>get_product_sales()</code></li>
<li>Implement <code>get_cashier_performance()</code></li>
<li>Implement <code>get_session_report()</code></li>
<li>Implement <code>get_payment_breakdown()</code></li>
<li>Implement <code>get_refund_summary()</code></li>
<li>Add IST timezone conversion to all date filters</li>
</ul>
</div>
</div>
<div class="pr-phase">
<div class="pr-phase-hdr"><span class="pr-phase-num">3</span><span class="pr-phase-title">Phase 3 — Report Views</span><span class="pr-phase-sub">Week 3</span></div>
<div class="pr-phase-body">
<ul class="pr-checklist">
<li>Create QWeb PDF report template (daily summary)</li>
<li>Create QWeb PDF report template (product breakdown)</li>
<li>Create QWeb PDF report template (cashier performance)</li>
<li>Create Excel export (XLSX via xlsxwriter or openpyxl)</li>
<li>Add report button to POS Orders list view</li>
</ul>
</div>
</div>
<div class="pr-phase">
<div class="pr-phase-hdr"><span class="pr-phase-num">4</span><span class="pr-phase-title">Phase 4 — Dashboard Widget</span><span class="pr-phase-sub">Week 4</span></div>
<div class="pr-phase-body">
<ul class="pr-checklist">
<li>Create Odoo backend Dashboard action for POS</li>
<li>Add KPI tiles (today revenue, orders, AOV, top product)</li>
<li>Add bar chart (hourly revenue pattern)</li>
<li>Add pie chart (payment method split)</li>
<li>Add table (top 10 products today)</li>
</ul>
</div>
</div>
<div class="pr-phase">
<div class="pr-phase-hdr"><span class="pr-phase-num">5</span><span class="pr-phase-title">Phase 5 — Automation</span><span class="pr-phase-sub">Week 5</span></div>
<div class="pr-phase-body">
<ul class="pr-checklist">
<li>Scheduled daily summary email to owner (7 AM)</li>
<li>Scheduled weekly product report email (Monday 8 AM)</li>
<li>Low-stock alert when top-selling POS products fall below threshold</li>
<li>End-of-session auto-PDF sent to manager WhatsApp</li>
</ul>
</div>
</div>
</section>
<section class="pr-part" id="p5">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 5</span>
<span class="pr-part-title">Key SQL Queries</span>
</div>
<h2 class="pr-h2">Query 1: Full Daily Report with Payment Split</h2>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
DATE(po.date_order AT TIME ZONE 'Asia/Kolkata') AS order_date,
COUNT(*) AS orders,
ROUND(SUM(po.amount_total)::numeric, 2) AS gross_revenue,
ROUND(SUM(po.amount_tax)::numeric, 2) AS tax,
ROUND(SUM(po.amount_total - po.amount_tax)::numeric, 2) AS net_revenue,
ROUND(AVG(po.amount_total)::numeric, 2) AS aov,
ROUND(SUM(CASE WHEN ppm.name ILIKE '%cash%'
THEN pp.amount ELSE 0 END)::numeric, 2) AS cash_collected,
ROUND(SUM(CASE WHEN ppm.name ILIKE '%card%'
THEN pp.amount ELSE 0 END)::numeric, 2) AS card_collected,
ROUND(SUM(CASE WHEN ppm.name ILIKE '%upi%'
THEN pp.amount ELSE 0 END)::numeric, 2) AS upi_collected
FROM pos_order po
LEFT JOIN pos_payment pp ON pp.pos_order_id = po.id
LEFT JOIN pos_payment_method ppm ON ppm.id = pp.payment_method_id
WHERE po.state IN ('paid','done','invoiced')
AND po.date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY DATE(po.date_order AT TIME ZONE 'Asia/Kolkata')
ORDER BY order_date;</pre>
</div>
<h2 class="pr-h2">Query 2: Top 20 Products This Month</h2>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
pt.name AS product,
pc.name AS category,
ROUND(SUM(pol.qty)::numeric, 2) AS qty_sold,
ROUND(SUM(pol.price_subtotal_incl)::numeric, 2) AS revenue,
ROUND(AVG(pol.price_unit)::numeric, 2) AS avg_price,
ROUND(AVG(pol.discount)::numeric, 2) AS avg_discount_pct,
COUNT(DISTINCT pol.order_id) AS in_orders
FROM pos_order_line pol
JOIN pos_order po ON po.id = pol.order_id
JOIN product_product pp ON pp.id = pol.product_id
JOIN product_template pt ON pt.id = pp.product_tmpl_id
LEFT JOIN product_category pc ON pc.id = pt.categ_id
WHERE po.state IN ('paid','done','invoiced')
AND DATE_TRUNC('month', po.date_order) = DATE_TRUNC('month', CURRENT_DATE)
GROUP BY pt.name, pc.name
ORDER BY revenue DESC
LIMIT 20;</pre>
</div>
<h2 class="pr-h2">Query 3: Cashier Leaderboard</h2>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
ru.name AS cashier,
COUNT(po.id) AS orders,
ROUND(SUM(po.amount_total)::numeric, 2) AS revenue,
ROUND(AVG(po.amount_total)::numeric, 2) AS aov,
ROUND(SUM(pol_totals.item_qty) / COUNT(po.id), 2) AS avg_basket_items,
COUNT(DISTINCT po.session_id) AS sessions
FROM pos_order po
JOIN res_users ru ON ru.id = po.user_id
JOIN (
SELECT order_id, SUM(qty) AS item_qty
FROM pos_order_line
GROUP BY order_id
) pol_totals ON pol_totals.order_id = po.id
WHERE po.state IN ('paid','done','invoiced')
AND po.date_order BETWEEN %(date_from)s AND %(date_to)s
GROUP BY ru.name
ORDER BY revenue DESC;</pre>
</div>
<h2 class="pr-h2">Query 4: Session Cash Reconciliation</h2>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">SQL</span></div>
<pre class="pr-code">SELECT
ps.name AS session,
ru.name AS cashier,
ps.start_at AS opened,
ps.stop_at AS closed,
ROUND(ps.cash_register_balance_start::numeric, 2) AS opening_float,
ROUND(cash_totals.cash_in::numeric, 2) AS cash_sales,
ROUND((ps.cash_register_balance_start + cash_totals.cash_in)::numeric, 2) AS expected,
ROUND(COALESCE(ps.cash_register_balance_end_real, 0)::numeric, 2) AS counted,
ROUND((COALESCE(ps.cash_register_balance_end_real, 0)
- ps.cash_register_balance_start
- cash_totals.cash_in)::numeric, 2) AS variance
FROM pos_session ps
JOIN res_users ru ON ru.id = ps.user_id
JOIN (
SELECT pp.session_id, SUM(pp.amount) AS cash_in
FROM pos_payment pp
JOIN pos_payment_method ppm ON ppm.id = pp.payment_method_id
WHERE ppm.is_cash_count = TRUE
GROUP BY pp.session_id
) cash_totals ON cash_totals.session_id = ps.id
WHERE ps.state = 'closed'
AND ps.start_at BETWEEN %(date_from)s AND %(date_to)s
ORDER BY ps.start_at DESC;</pre>
</div>
</section>
<section class="pr-part" id="p6">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 6</span>
<span class="pr-part-title">Module Structure</span>
</div>
<pre class="pr-tree">miller_pos_report/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── pos_report_wizard.py (TransientModel — wizard)
├── reports/
│ ├── pos_daily_report.py
│ ├── pos_product_report.py
│ ├── pos_cashier_report.py
│ ├── pos_session_report.py
│ └── pos_payment_report.py
├── views/
│ ├── pos_report_wizard_views.xml
│ └── pos_report_actions.xml
├── report/
│ ├── templates/
│ │ ├── pos_daily_report.xml (QWeb PDF template)
│ │ ├── pos_product_report.xml
│ │ ├── pos_cashier_report.xml
│ │ └── pos_session_report.xml
│ └── pos_report.xml (ir.actions.report registrations)
├── data/
│ └── pos_report_menus.xml
└── static/
└── description/
└── icon.png</pre>
<h2 class="pr-h2">Sample __manifest__.py</h2>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span></div>
<pre class="pr-code">{
'name': 'Miller POS Report',
'version': '16.0.1.0.0',
'summary': 'Comprehensive POS Orders reports for Miller Stores',
'author': 'Miller Stores',
'depends': ['point_of_sale', 'report_xlsx'],
'data': [
'security/ir.model.access.csv',
'data/pos_report_menus.xml',
'views/pos_report_wizard_views.xml',
'views/pos_report_actions.xml',
'report/pos_report.xml',
'report/templates/pos_daily_report.xml',
'report/templates/pos_product_report.xml',
'report/templates/pos_cashier_report.xml',
'report/templates/pos_session_report.xml',
],
'installable': True,
'application': False,
}</pre>
</div>
<h2 class="pr-h2">Menu Registration (POS → Reporting → Orders)</h2>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">XML</span><span class="pr-codetitle">data/pos_report_menus.xml</span></div>
<pre class="pr-code"><!-- Add "Miller Reports" under POS > Reporting -->
<menuitem id="menu_miller_pos_reports"
name="Miller Reports"
parent="point_of_sale.menu_point_root"
sequence="30"/>
<menuitem id="menu_pos_daily_report"
name="Daily Summary"
parent="menu_miller_pos_reports"
action="action_pos_daily_report_wizard"
sequence="10"/>
<menuitem id="menu_pos_product_report"
name="Product Sales"
parent="menu_miller_pos_reports"
action="action_pos_product_report_wizard"
sequence="20"/>
<menuitem id="menu_pos_cashier_report"
name="Cashier Performance"
parent="menu_miller_pos_reports"
action="action_pos_cashier_report_wizard"
sequence="30"/></pre>
</div>
</section>
<section class="pr-part" id="p7">
<div class="pr-part-hdr">
<span class="pr-part-num">Part 7</span>
<span class="pr-part-title">Dashboard Controller Example</span>
</div>
<p class="pr-p">Single endpoint returning all KPIs for the POS dashboard, callable from a JS widget or external BI tool.</p>
<div class="pr-codewrap">
<div class="pr-codehdr"><span class="pr-lang">Python</span><span class="pr-codetitle">controllers/pos_dashboard.py</span></div>
<pre class="pr-code">from odoo import http
from odoo.http import request
from datetime import datetime, date
class MillerPOSDashboard(http.Controller):
@http.route('/miller/pos/dashboard', auth='user', type='json')
def pos_dashboard(self, **kwargs):
today = date.today()
month_start = today.replace(day=1)
PosOrder = request.env['pos.order']
# Today's KPIs
today_orders = PosOrder.search([
('date_order', '>=', datetime.combine(today, datetime.min.time())),
('state', 'in', ['paid','done','invoiced']),
])
today_revenue = sum(o.amount_total for o in today_orders)
today_aov = today_revenue / len(today_orders) if today_orders else 0
# Month-to-date KPIs
mtd_orders = PosOrder.search([
('date_order', '>=', datetime.combine(month_start, datetime.min.time())),
('state', 'in', ['paid','done','invoiced']),
])
mtd_revenue = sum(o.amount_total for o in mtd_orders)
# Top product today (by revenue)
from collections import defaultdict
prod_rev = defaultdict(float)
for o in today_orders:
for line in o.lines:
prod_rev[line.product_id.name] += line.price_subtotal_incl
top_product = max(prod_rev, key=prod_rev.get) if prod_rev else None
# Active sessions
active_sessions = request.env['pos.session'].search_count([
('state', '=', 'opened')
])
return {
'today': {
'revenue': round(today_revenue, 2),
'orders': len(today_orders),
'aov': round(today_aov, 2),
'top_product': top_product,
},
'mtd': {
'revenue': round(mtd_revenue, 2),
'orders': len(mtd_orders),
},
'active_sessions': active_sessions,
}</pre>
</div>
<div class="pr-note">This endpoint is called from the POS backend dashboard JS widget. Protected by <code>auth='user'</code> so only authenticated Odoo users can access it. All monetary values are in INR (₹).</div>
</section>
</main>
</div>
<div class="pr-overlay" id="pr-overlay"></div>
<button class="pr-menu-btn" id="pr-menu-btn" aria-label="Open menu">☰</button>
</div>
<script>
var nav = document.getElementById('pr-nav');
var btn = document.getElementById('pr-menu-btn');
var ovl = document.getElementById('pr-overlay');
if (btn) btn.addEventListener('click', function(){ nav.classList.toggle('open'); ovl.style.display = nav.classList.contains('open') ? 'block' : 'none'; });
if (ovl) ovl.addEventListener('click', function(){ nav.classList.remove('open'); ovl.style.display = 'none'; });
var sections = document.querySelectorAll('[id]');
var links = document.querySelectorAll('.pr-nav a[href^="#"]');
function onScroll() {
var scrollY = window.scrollY + 90;
var cur = '';
sections.forEach(function(s){ if (s.offsetTop <= scrollY) cur = s.id; });
links.forEach(function(a){
a.classList.toggle('active', a.getAttribute('href') === '#' + cur);
});
}
window.addEventListener('scroll', onScroll, {passive:true});
onScroll();
links.forEach(function(a){
a.addEventListener('click', function(e){
var t = document.querySelector(a.getAttribute('href'));
if (t){ e.preventDefault(); t.scrollIntoView({behavior:'smooth',block:'start'}); }
if (window.innerWidth < 861){ nav.classList.remove('open'); ovl.style.display='none'; }
});
});
</script>
</main>
<footer id="bottom" data-anchor="true" data-name="Footer" class="o_footer o_colored_level o_cc ">
<div id="footer" class="oe_structure oe_structure_solo">
<section class="s_text_block pt0 pb0" data-snippet="s_text_block" data-name="Text" style="background-image: none;" id="Explore" data-anchor="true">
<div class="container">
<div class="row o_grid_mode" data-row-count="6">
<div class="o_colored_level o_grid_item g-col-lg-2 g-height-6 col-lg-2" style="grid-row-start: 1; grid-column-start: 1; grid-row-end: 7; grid-column-end: 3; z-index: 1;">
<h5>Explore</h5>
<ul class="list-unstyled">
<li class="list-item py-1"><a href="/">Home</a></li>
<li class="list-item py-1"><a href="/about-us" data-bs-original-title="" title="">Our Company</a></li>
<li class="list-item py-1"><a href="/labreports" data-bs-original-title="" title="">Lab reports</a><br></li>
<li class="list-item py-1"><a href="/blog" data-bs-original-title="" title="">Blog</a></li>
<li class="list-item py-1">
<a href="/privacy" data-bs-original-title="" title="">Privacy Policy</a>
</li><li class="list-item py-1">
<a href="/helpdesk" data-bs-original-title="" title="">Help</a>
</li>
</ul>
</div>
<div class="o_colored_level o_grid_item g-height-6 g-col-lg-2 col-lg-2" style="grid-row-start: 1; grid-column-start: 3; grid-row-end: 7; grid-column-end: 5; z-index: 2;">
<h5>Services</h5>
<p><a href="/returnpolicy">Return Policy</a></p><p><a href="/dataprotection" data-bs-original-title="" title="">Terms of services</a><br></p><p><a href="/refundpolicy" data-bs-original-title="" title="">Refund Policy</a></p><p><a href="/shipping-policy" data-bs-original-title="" title="">shipping policy</a><br></p>
</div>
<div class="o_colored_level o_grid_item g-col-lg-2 g-height-6 col-lg-2" style="grid-row-start: 1; grid-column-start: 5; grid-row-end: 7; grid-column-end: 7; z-index: 3;">
<h5>Follow us</h5>
<ul class="list-unstyled">
<li class="py-1"><i class="fa fa-1x fa-fw fa-facebook-square me-2"></i><a href="https://www.facebook.com/millerstoresofficial/" target="_blank" data-bs-original-title="" title="">Facebook</a></li>
<li class="py-1"><i class="fa fa-1x fa-fw fa-instagram me-2"></i><a href="https://www.instagram.com/millerstores.online/" target="_blank" data-bs-original-title="" title="">Instagram</a></li><li class="py-1"><i class="fa fa-youtube fa-1x fa-fw me-2" data-bs-original-title="" title="" aria-describedby="tooltip384366"></i><a href="https://www.youtube.com/@THEMILLERSTORES" data-bs-original-title="" title="">YouTube</a></li><li class="py-1"><i class="fa fa-google fa-1x fa-fw me-2"></i><a href="https://www.google.com/preferences/source?q=millerstores.com" target="_blank" rel="noopener">Add us as a preferred source on Google</a></li>
</ul>
</div>
<div class="o_colored_level o_grid_item g-height-6 g-col-lg-3 col-lg-3" style="z-index: 5; grid-row-start: 1; grid-column-start: 10; grid-row-end: 7; grid-column-end: 13;">
<p>MILLER FOODS PRIVATE LIMITED</p><p>PANANGAD, Ernakulam, Kerala-682506</p></div><div class="o_colored_level o_grid_item g-height-6 g-col-lg-3 col-lg-3" style="z-index: 4; grid-row-start: 1; grid-column-start: 7; grid-row-end: 7; grid-column-end: 10;">
<h5>Get in touch</h5>
<ul class="list-unstyled">
<li class="py-1"><i class="fa fa-1x fa-fw fa-envelope me-2"></i><a href="mailto:info@millerstores.com">info@millerstores.com</a></li>
<li class="py-1"><i class="fa fa-1x fa-fw fa-phone me-2"></i><span class="o_force_ltr"><a href="tel:+91 7205817205" data-bs-original-title="" title="">+91 7205817205</a></span></li>
</ul>
</div></div>
</div>
</section>
</div>
<div class="o_footer_copyright o_colored_level o_cc" data-name="Copyright">
<div class="container py-3">
<div class="row">
<div class="col-sm text-center text-sm-start text-muted">
<span class="o_footer_copyright_name me-2">info@millerstores.com</span>
</div>
<div class="col-sm text-center text-sm-end o_not_editable">
<div class="o_brand_promotion">
Powered by
<a target="_blank" class="badge text-bg-light" href="http://www.odoo.com?utm_source=db&utm_medium=website">
<img alt="Odoo" src="https://www.millerstores.com/web/static/img/odoo_logo_tiny.png" width="62" height="20" style="width: auto; height: 1em; vertical-align: baseline;" loading="lazy">
</a>
-
The #1 <a target="_blank" href="http://www.odoo.com/app/ecommerce?utm_source=db&utm_medium=website">Open Source eCommerce</a>
</div>
</div>
</div>
</div>
</div>
</footer>
</div>
<script id="plausible_script" name="plausible" defer="defer" data-domain="themillers.odoo.com" src="https://plausible.io/js/plausible.js"></script>
<script>
(function() {
var qs = new URLSearchParams(location.search);
if (qs.get('labtest') === '1') {
document.cookie = 'ms_labtest=1; path=/; max-age=86400';
var u = new URL(location.href); u.searchParams.delete('labtest');
location.replace(u.toString());
} else if (qs.get('labtest') === '0') {
document.cookie = 'ms_labtest=; path=/; max-age=0';
var u2 = new URL(location.href); u2.searchParams.delete('labtest');
location.replace(u2.toString());
}
})();
</script>
<style>
#msd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 2000;
opacity: 0; pointer-events: none; transition: opacity .2s; }
#msd-overlay.show { opacity: 1; pointer-events: auto; }
#msd-drawer { position: fixed; top: 0; right: 0; height: 100%; width: 400px; max-width: 92vw;
background: #fff; z-index: 2001; display: flex; flex-direction: column;
transform: translateX(100%); transition: transform .25s ease; box-shadow: -6px 0 24px rgba(0,0,0,.15); }
#msd-drawer.show { transform: translateX(0); }
@media (max-width: 767px) {
#msd-drawer { width: 100%; max-width: 100%; }
}
#msd-head { display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid #eee; font-weight: 700; font-size: 16px; }
#msd-close { border: none; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: #555; }
#msd-cart-total { font-weight: 700; color: #277614; font-size: 14px; margin-left: 8px; }
#msd-progress { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; }
#msd-progress-track { height: 6px; background: #eee; border-radius: 4px; overflow: hidden; margin-bottom: 8px; }
#msd-progress-fill { height: 100%; background: #277614; width: 0%; transition: width .3s; }
#msd-progress-labels { display: flex; justify-content: space-between; font-size: 11px; color: #777; gap: 6px; }
#msd-progress-labels span.msd-hit { color: #277614; font-weight: 700; }
#msd-body { flex: 1; overflow-y: auto; padding: 8px 16px 16px; }
.msd-line { display: flex; gap: 12px; background: #f5f5f5; border-radius: 14px;
padding: 12px; margin-bottom: 10px; align-items: flex-start; }
.msd-line-img { width: 64px; height: 64px; border-radius: 12px; object-fit: cover;
flex-shrink: 0; background: #fff; }
.msd-line-info { flex: 1; min-width: 0; }
.msd-line-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.msd-line-name { font-weight: 700; font-size: 14px; color: #1a1a1a; line-height: 1.3; }
.msd-line-price { font-weight: 800; font-size: 15px; color: #1a1a1a; white-space: nowrap; }
.msd-line-size { font-size: 12px; color: #888; margin-top: 2px; margin-bottom: 10px; }
.msd-line-controls { display: flex; align-items: center; gap: 8px; }
.msd-line-del { width: 34px; height: 34px; border-radius: 9px; border: 1px solid #ddd;
background: #fff; display: flex; align-items: center; justify-content: center;
color: #555; cursor: pointer; flex-shrink: 0; font-size: 14px; }
.msd-line-qty { display: flex; align-items: center; border: 1px solid #ddd; border-radius: 9px;
overflow: hidden; background: #fff; }
.msd-line-qty button { width: 32px; height: 34px; border: none; background: #fff;
font-size: 16px; font-weight: 700; color: #333; cursor: pointer; }
.msd-line-qty span { width: 26px; text-align: center; font-weight: 700; font-size: 13px; color: #1a1a1a; }
#msd-coupon { margin-top: 10px; padding: 10px; background: #f6faf3; border: 1px dashed #277614;
border-radius: 8px; font-size: 12.5px; }
#msd-coupon-suggest { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
#msd-coupon-apply { background: #277614; color: #fff; border: none; border-radius: 6px;
padding: 5px 12px; font-size: 12px; font-weight: 700; cursor: pointer; }
#msd-coupon-slot { display: none; }
#msd-coupon-msg { margin-top: 6px; font-size: 11.5px; }
#msd-crosssell { margin-top: 14px; }
#msd-tabs { display: flex; gap: 6px; margin-bottom: 8px; }
.msd-tab { border: 1px solid #ddd; background: #fff; border-radius: 20px; padding: 5px 12px;
font-size: 11.5px; font-weight: 600; cursor: pointer; color: #555; }
.msd-tab.active { background: #277614; color: #fff; border-color: #277614; }
#msd-tab-panel { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.msd-xcard { flex: 0 0 110px; border: 1px solid #eee; border-radius: 8px; padding: 6px; text-align: center; }
.msd-xcard img { width: 100%; aspect-ratio: 1/1; object-fit: cover; border-radius: 6px; margin-bottom: 4px; }
.msd-xcard .msd-xname { font-size: 10.5px; font-weight: 600; height: 26px; overflow: hidden; }
.msd-xcard .msd-xprice { font-size: 11px; font-weight: 700; margin: 3px 0; }
.msd-xcard button { width: 100%; font-size: 10.5px; font-weight: 700; border: 1px solid #277614;
color: #277614; background: #fff; border-radius: 5px; padding: 3px; cursor: pointer; }
#msd-footer { border-top: 1px solid #eee; padding: 12px 16px 16px; }
#msd-delivery-drawer-notice { font-size: 11px; color: #888; text-align: center; margin-bottom: 8px; }
#msd-checkout-btn { display: block; width: 100%; text-align: center; background: #277614; color: #fff;
font-weight: 700; padding: 12px; border-radius: 8px; border: none; font-size: 14px; cursor: pointer; }
/* Real phone-OTP login dialog: relocated (not cloned) into our own bottom-right shell */
#msd-otp-shell { display: none; position: fixed; z-index: 2100; }
#msd-otp-shell.show { display: block; }
#msd-otp-shell .modal-content { box-shadow: 0 12px 40px rgba(0,0,0,.16) !important; border: none !important;
border-radius: 20px !important; background: #fff !important; }
@media (min-width: 768px) {
#msd-otp-shell { bottom: 20px; right: 20px; width: 400px; max-width: 92vw; }
}
@media (max-width: 767px) {
#msd-otp-shell { left: 0; right: 0; bottom: 0; width: 100%; }
#msd-otp-shell .modal-content { border-radius: 20px 20px 0 0 !important; }
}
/* Kill any default colored background on the dialog / its layout wrappers */
.login_dialog, .login_dialog .row, .login_dialog [class*="col-"] { background: #fff !important; }
.login_dialog .col-lg-8 { display: none !important; }
.login_dialog .col-lg-4 { flex: 0 0 100% !important; max-width: 100% !important;
padding: 28px 22px 24px !important; }
.login_dialog #mobileInputSection h4, .login_dialog #otpSection h5 {
font-size: 20px !important; font-weight: 800 !important; margin-top: 0 !important; color: #1a1a1a !important; }
.login_dialog .mobile_number, .login_dialog #otpInput, .login_dialog #EmailInput {
border: none !important; border-radius: 0 !important;
padding: 14px 16px !important; font-size: 15px !important; background: transparent !important; }
.login_dialog .mobile_number:focus, .login_dialog #otpInput:focus, .login_dialog #EmailInput:focus {
outline: none !important; box-shadow: none !important; }
.login_dialog #verifyOtpBtn, .login_dialog #senEmailBtn {
background: #277614 !important; border-color: #277614 !important; border-radius: 999px !important;
font-weight: 700 !important; letter-spacing: .03em; padding: 15px !important; font-size: 15px !important;
text-transform: uppercase; box-shadow: 0 4px 14px rgba(39,118,20,.28) !important; }
.login_dialog #verifyOtpBtn:hover, .login_dialog #senEmailBtn:hover {
background: #1f5f10 !important; border-color: #1f5f10 !important; }
.login_dialog .close_modal { color: #999 !important; top: 16px !important; right: 18px !important; }
.login_dialog small, .login_dialog .text-muted { font-size: 12px !important; color: #8a8a8a !important;
text-align: center; display: block; margin-top: 14px !important; line-height: 1.5; }
.login_dialog small a, .login_dialog .text-muted a { color: #8a8a8a !important; text-decoration: underline !important; }
.msd-cc-prefix { padding: 14px 14px; background: transparent; font-size: 14.5px; font-weight: 600;
white-space: nowrap; flex-shrink: 0; display: flex; align-items: center; gap: 6px; color: #1a1a1a; }
.msd-cc-chevron { font-size: 10px; color: #999; margin-left: 1px; }
.msd-phone-wrap { display: flex; align-items: center; border: 1.5px solid #e5e5e5; border-radius: 14px;
overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.03); }
.msd-phone-wrap .mobile_number { border: none !important; border-radius: 0 !important; flex: 1; }
.msd-tb-subtitle { font-size: 13px; color: #777; margin-bottom: 20px; }
/* Exit-intent feedback survey shown when the OTP popup is closed */
#msd-exit-survey { display: none; position: fixed; z-index: 2100; background: #fff; border-radius: 20px;
box-shadow: 0 12px 40px rgba(0,0,0,.16); overflow: hidden; }
#msd-exit-survey.show { display: flex; flex-direction: column; }
@media (min-width: 768px) {
#msd-exit-survey { bottom: 20px; right: 20px; width: 400px; max-width: 92vw; max-height: 560px; }
}
@media (max-width: 767px) {
#msd-exit-survey { left: 0; right: 0; bottom: 0; width: 100%; max-height: 82vh; border-radius: 20px 20px 0 0; }
}
#msd-es-head { display: flex; align-items: center; justify-content: space-between; padding: 20px 22px 4px; flex-shrink: 0; }
#msd-es-head h4 { font-size: 19px; font-weight: 800; margin: 0; color: #1a1a1a; }
#msd-es-close { border: none; background: none; font-size: 20px; line-height: 1; cursor: pointer; color: #555; padding: 4px; }
#msd-es-list { overflow-y: auto; padding: 10px 22px 4px; flex: 1; }
.msd-es-opt { display: flex; align-items: center; gap: 12px; padding: 10px 0; cursor: pointer; font-size: 14.5px; color: #333; }
.msd-es-opt input[type="radio"] { width: 18px; height: 18px; accent-color: #277614; cursor: pointer; flex-shrink: 0; }
#msd-es-other-wrap { display: none; padding: 6px 22px 4px; flex-shrink: 0; }
#msd-es-other-wrap.show { display: block; }
#msd-es-other-wrap label { font-size: 12.5px; font-weight: 700; color: #333; display: block; margin-bottom: 6px; }
#msd-es-other-wrap textarea { width: 100%; border: 1.5px solid #e5e5e5; border-radius: 10px; padding: 10px 12px;
font-size: 13.5px; resize: none; min-height: 60px; font-family: inherit; box-sizing: border-box; }
#msd-es-other-wrap textarea:focus { outline: none; border-color: #277614; }
#msd-es-footer { padding: 14px 22px 22px; flex-shrink: 0; }
#msd-es-submit { display: block; width: 100%; text-align: center; background: #277614; color: #fff;
font-weight: 700; padding: 15px; border-radius: 999px; border: none; font-size: 14.5px; cursor: pointer;
text-transform: uppercase; letter-spacing: .03em; box-shadow: 0 4px 14px rgba(39,118,20,.28); }
#msd-es-submit:hover { background: #1f5f10; }
#msd-es-skip { display: block; width: 100%; text-align: center; background: #fff; color: #333;
font-weight: 700; padding: 14px; border-radius: 999px; border: 1.5px solid #ddd; font-size: 14.5px;
cursor: pointer; margin-top: 10px; }
#msd-es-skip:hover { background: #f7f7f7; }
#msd-checkout-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 2050;
opacity: 0; pointer-events: none; transition: opacity .2s; }
#msd-checkout-overlay.show { opacity: 1; pointer-events: auto; }
#msd-checkout-panel { position: fixed; top: 0; right: 0; height: 100%; width: 560px; max-width: 94vw;
background: #fff; z-index: 2051; display: flex; flex-direction: column;
transform: translateX(100%); transition: transform .25s ease; box-shadow: -6px 0 24px rgba(0,0,0,.15); }
#msd-checkout-panel.show { transform: translateX(0); }
@media (max-width: 767px) { #msd-checkout-panel { width: 100%; max-width: 100%; } }
#msd-co-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px;
border-bottom: 1px solid #eee; font-weight: 700; font-size: 16px; flex-shrink: 0; }
#msd-co-close { border: none; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: #555; }
#msd-co-body { flex: 1; overflow-y: auto; padding: 10px 16px 24px; }
#msd-co-loading { text-align: center; color: #999; padding: 60px 0; }
#msd-co-body .wizard { margin-bottom: 8px; }
#msd-co-body .oe_website_sale { padding: 0 !important; }
#msd-co-body .row { display: block !important; }
#msd-co-body [class*="col-"] { width: 100% !important; max-width: 100% !important; flex: none !important; }
#msd-co-body .toggle_summary { display: none !important; }
#msd-co-body .toggle_summary_div { display: block !important; }
#msd-co-body table { display: block !important; overflow-x: auto !important; width: 100% !important; }
#msd-co-body .btn-primary, #msd-co-body .btn-secondary { border-radius: 8px !important; }
#msd-co-body .btn-primary { background: #277614 !important; border-color: #277614 !important; }
#msd-toast { position: fixed; left: 12px; right: 12px; bottom: 12px; background: #1a1a1a; color: #fff;
border-radius: 10px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between;
gap: 10px; z-index: 2002; opacity: 0; transform: translateY(12px); pointer-events: none;
transition: opacity .2s, transform .2s; font-size: 13px; }
#msd-toast.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
#msd-toast-view { color: #9be08a; font-weight: 700; text-decoration: none; white-space: nowrap; }
@media (min-width: 992px) { #msd-toast { display: none; } }
/* Removed "position: relative" here — this theme centers the logo using
its own absolute positioning, and overriding it broke that centering,
making the logo effectively invisible specifically on mobile's
tighter, hamburger-collapsed header layout. The pulse animation below
is applied to the <img> itself and doesn't need this on the parent. */
.navbar-brand.logo.msd-logo-pulse img { animation: msd-pulse .6s ease; }
@keyframes msd-pulse {
0% { transform: scale(1); }
35% { transform: scale(1.18) rotate(-4deg); }
70% { transform: scale(0.95) rotate(3deg); }
100% { transform: scale(1); }
}
.my_cart_quantity.msd-bump { animation: msd-bump .4s ease; }
@keyframes msd-bump { 0% { transform: scale(1); } 50% { transform: scale(1.35); } 100% { transform: scale(1); } }
body.msd-pdp .js_add_cart_variants .form-check { position: relative; display: block;
border: 1.5px solid #ddd; border-radius: 8px; padding: 8px 10px; margin: 0; cursor: pointer; }
body.msd-pdp .js_add_cart_variants .form-check input.js_variant_change { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
body.msd-pdp .js_add_cart_variants .form-check:has(input:checked) { border-color: #277614; background: #f6faf3; }
body.msd-pdp .js_add_cart_variants ul.o_wsale_product_attribute { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; }
body.msd-pdp .js_add_cart_variants li.list-inline-item { margin: 0 !important; }
body.msd-pdp .msd-per-unit { display: block; font-size: 10.5px; color: #888; margin-top: 2px; }
#add_to_cart.msd-spinning { pointer-events: none; opacity: .75; }
#add_to_cart.msd-spinning .fa-shopping-cart:before { content: ""; display: inline-block;
width: 13px; height: 13px; border: 2px solid rgba(255,255,255,.5); border-top-color: #fff;
border-radius: 50%; animation: msd-spin .6s linear infinite; }
@keyframes msd-spin { to { transform: rotate(360deg); } }
#msd-sticky-pdp { position: fixed; left: 0; right: 0; bottom: 0; background: #fff;
border-top: 1px solid #e2e2e2; box-shadow: 0 -4px 14px rgba(0,0,0,.08); z-index: 1500;
display: none; align-items: center; gap: 10px; padding: 8px 14px; }
#msd-sticky-pdp.show { display: flex; }
#msd-sticky-pdp img { width: 38px; height: 38px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
#msd-sticky-pdp .msd-sp-info { flex: 1; min-width: 0; }
#msd-sticky-pdp .msd-sp-name { font-size: 11.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#msd-sticky-pdp .msd-sp-price { font-size: 12.5px; font-weight: 800; }
#msd-sticky-pdp button { background: #277614; color: #fff; border: none; border-radius: 7px;
padding: 9px 16px; font-weight: 700; font-size: 12.5px; cursor: pointer; flex-shrink: 0; }
</style>
<div id="msd-overlay"></div>
<aside id="msd-drawer">
<div id="msd-head">
<span>Your Cart <span id="msd-cart-total">₹0</span></span>
<button id="msd-close" aria-label="Close cart">✕</button>
</div>
<div id="msd-progress">
<div id="msd-progress-track"><div id="msd-progress-fill"></div></div>
<div id="msd-progress-labels">
<span id="msd-mile-1">Free delivery at ₹2,500</span>
</div>
</div>
<div id="msd-body">
<div id="msd-lines">Loading…</div>
<div id="msd-coupon">
<div id="msd-coupon-slot"></div>
<div id="msd-coupon-msg"></div>
</div>
<div id="msd-crosssell">
<div id="msd-tabs"><button class="msd-tab active" type="button">Frequently Bought Together</button></div>
<div id="msd-tab-panel"></div>
</div>
<div id="msd-won"></div>
</div>
<div id="msd-footer">
<div id="msd-delivery-drawer-notice">Delivery charges will be calculated at the checkout.</div>
<button type="button" id="msd-checkout-btn">Checkout</button>
</div>
</aside>
<div id="msd-toast"><span id="msd-toast-msg"></span><a href="#" id="msd-toast-view">View Cart</a></div>
<div id="msd-checkout-overlay"></div>
<aside id="msd-checkout-panel">
<div id="msd-co-head">
<span id="msd-co-title">Checkout</span>
<button type="button" id="msd-co-close" aria-label="Close">✕</button>
</div>
<div id="msd-co-body"><div id="msd-co-loading">Loading…</div></div>
</aside>
<script type="application/json" id="msd-crosssell-data">
{"accessory": {"3183": [{"id": 565, "vid": 565, "name": "MILLER MILLET DOSA MIX", "price": 190, "img": "/web/image/product.template/565/image_128"}, {"id": 2207, "vid": 2207, "name": "MILLET PREMIUM UPMA MIX 200G", "price": 199, "img": "/web/image/product.template/2207/image_128"}, {"id": 2199, "vid": 2199, "name": "MIX MILLET NOODLES 180 GM", "price": 189, "img": "/web/image/product.template/2199/image_128"}], "4545": [{"id": 1881, "vid": 1881, "name": "PREMIUM GREEN CORIANDER POWDER 250GM", "price": 182, "img": "/web/image/product.template/1881/image_128"}], "3399": [{"id": 1905, "vid": 1905, "name": "HOT RED CHILLI POWDER (100GM)", "price": 75, "img": "/web/image/product.template/1905/image_128"}, {"id": 1909, "vid": 1909, "name": "PREMIUM GREEN CORIANDER POWDER 100GM", "price": 83, "img": "/web/image/product.template/1909/image_128"}], "3397": [{"id": 1905, "vid": 1905, "name": "HOT RED CHILLI POWDER (100GM)", "price": 75, "img": "/web/image/product.template/1905/image_128"}, {"id": 1909, "vid": 1909, "name": "PREMIUM GREEN CORIANDER POWDER 100GM", "price": 83, "img": "/web/image/product.template/1909/image_128"}], "3398": [{"id": 1905, "vid": 1905, "name": "HOT RED CHILLI POWDER (100GM)", "price": 75, "img": "/web/image/product.template/1905/image_128"}, {"id": 1909, "vid": 1909, "name": "PREMIUM GREEN CORIANDER POWDER 100GM", "price": 83, "img": "/web/image/product.template/1909/image_128"}], "451": [{"id": 1881, "vid": 1881, "name": "PREMIUM GREEN CORIANDER POWDER 250GM", "price": 182, "img": "/web/image/product.template/1881/image_128"}]}, "by_cat": {"31": [{"id": 9976, "vid": 3401, "name": "A2 PURE GIR COWGHEE", "price": 799, "img": "/web/image/product.template/9976/image_128"}, {"id": 10069, "vid": 3506, "name": "ASSAM GREEN TEA", "price": 180, "img": "/web/image/product.template/10069/image_128"}, {"id": 9990, "vid": 3415, "name": "BARNYARD MILLET", "price": 133, "img": "/web/image/product.template/9990/image_128"}, {"id": 10119, "vid": 3565, "name": "CHIASEEDS", "price": 109, "img": "/web/image/product.template/10119/image_128"}, {"id": 10028, "vid": 3453, "name": "COCONUT SUGAR", "price": 223, "img": "/web/image/product.template/10028/image_128"}, {"id": 10068, "vid": 3505, "name": "COCONUT VINEGAR", "price": 150, "img": "/web/image/product.template/10068/image_128"}], "32": [{"id": 9770, "vid": 3183, "name": "FOXTAIL MILLET POHA", "price": 146, "img": "/web/image/product.template/9770/image_128"}, {"id": 9966, "vid": 3388, "name": "INSTANT CHICKEN ROAST CURRY MIX", "price": 230, "img": "/web/image/product.template/9966/image_128"}, {"id": 9967, "vid": 3389, "name": "INSTANT MEAT ROAST CURRY MIX", "price": 230, "img": "/web/image/product.template/9967/image_128"}, {"id": 10045, "vid": 3481, "name": "INSTANT PALADA MIX", "price": 230, "img": "/web/image/product.template/10045/image_128"}, {"id": 9968, "vid": 3390, "name": "INSTANT PANEER BUTTER MASALA MIX", "price": 230, "img": "/web/image/product.template/9968/image_128"}, {"id": 10047, "vid": 3483, "name": "INSTANT SEMIYA PAYASAM MIX", "price": 146, "img": "/web/image/product.template/10047/image_128"}], "33": [{"id": 9994, "vid": 3419, "name": "BAY LEAF", "price": 89, "img": "/web/image/product.template/9994/image_128"}, {"id": 10082, "vid": 3520, "name": "BLACK CARDAMOM", "price": 185, "img": "/web/image/product.template/10082/image_128"}, {"id": 9984, "vid": 3471, "name": "BLACK PEPPER", "price": 151, "img": "/web/image/product.template/9984/image_128"}, {"id": 9999, "vid": 3476, "name": "CARDAMOM", "price": 340, "img": "/web/image/product.template/9999/image_128"}, {"id": 10026, "vid": 3554, "name": "CASSIA", "price": 194, "img": "/web/image/product.template/10026/image_128"}, {"id": 10010, "vid": 3435, "name": "CLOVES", "price": 180, "img": "/web/image/product.template/10010/image_128"}], "34": [{"id": 9957, "vid": 3375, "name": "CHAMMANDHI PODI", "price": 150, "img": "/web/image/product.template/9957/image_128"}, {"id": 9966, "vid": 3388, "name": "INSTANT CHICKEN ROAST CURRY MIX", "price": 230, "img": "/web/image/product.template/9966/image_128"}, {"id": 9967, "vid": 3389, "name": "INSTANT MEAT ROAST CURRY MIX", "price": 230, "img": "/web/image/product.template/9967/image_128"}, {"id": 9968, "vid": 3390, "name": "INSTANT PANEER BUTTER MASALA MIX", "price": 230, "img": "/web/image/product.template/9968/image_128"}, {"id": 10106, "vid": 3548, "name": "INSTANT SAMBAR POWDER", "price": 135, "img": "/web/image/product.template/10106/image_128"}, {"id": 10009, "vid": 3434, "name": "KERALA CHUTNEY POWDER", "price": 90, "img": "/web/image/product.template/10009/image_128"}], "35": [{"id": 9990, "vid": 3415, "name": "BARNYARD MILLET", "price": 133, "img": "/web/image/product.template/9990/image_128"}, {"id": 9770, "vid": 3183, "name": "FOXTAIL MILLET POHA", "price": 146, "img": "/web/image/product.template/9770/image_128"}, {"id": 10050, "vid": 3486, "name": "JOWAR FLAKES", "price": 138, "img": "/web/image/product.template/10050/image_128"}, {"id": 10049, "vid": 4247, "name": "JOWAR FLOUR", "price": 65, "img": "/web/image/product.template/10049/image_128"}, {"id": 10052, "vid": 3488, "name": "JOWAR PUTTUPODI", "price": 95, "img": "/web/image/product.template/10052/image_128"}, {"id": 10059, "vid": 4244, "name": "KUDO MILLET", "price": 138, "img": "/web/image/product.template/10059/image_128"}], "36": [{"id": 9983, "vid": 3408, "name": "BASMATI RICE", "price": 150, "img": "/web/image/product.template/9983/image_128"}, {"id": 10039, "vid": 3468, "name": "GREEN DAL PKT", "price": 125, "img": "/web/image/product.template/10039/image_128"}, {"id": 9964, "vid": 3384, "name": "IDLI RICE", "price": 206, "img": "/web/image/product.template/9964/image_128"}, {"id": 9970, "vid": 3392, "name": "INSTANT TAMARIND RICE MIX", "price": 112, "img": "/web/image/product.template/9970/image_128"}, {"id": 9971, "vid": 3393, "name": "INSTANT TOMATO RICE MIX", "price": 120, "img": "/web/image/product.template/9971/image_128"}, {"id": 10065, "vid": 3502, "name": "MATTA RICE", "price": 118, "img": "/web/image/product.template/10065/image_128"}], "37": [{"id": 10086, "vid": 3575, "name": "BROWN CORIANDER POWDER", "price": 100, "img": "/web/image/product.template/10086/image_128"}, {"id": 10019, "vid": 3538, "name": "GREEN PREMIUM CORIANDER POWDER", "price": 80, "img": "/web/image/product.template/10019/image_128"}, {"id": 10085, "vid": 3577, "name": "Guntur CHILI POWDER", "price": 175, "img": "/web/image/product.template/10085/image_128"}, {"id": 10072, "vid": 3509, "name": "Lakadong Turmeric Powder", "price": 127, "img": "/web/image/product.template/10072/image_128"}, {"id": 9974, "vid": 3399, "name": "PREMIUM KASHMIRI CHILI (POWDER)", "price": 190, "img": "/web/image/product.template/9974/image_128"}, {"id": 10007, "vid": 3455, "name": "Spicy Chili (Karnataka)", "price": 81, "img": "/web/image/product.template/10007/image_128"}], "38": [{"id": 10011, "vid": 3436, "name": "COLD PRESSED ALMOND OIL", "price": 570, "img": "/web/image/product.template/10011/image_128"}, {"id": 10012, "vid": 3437, "name": "COLD PRESSED COCONUT OIL", "price": 315, "img": "/web/image/product.template/10012/image_128"}, {"id": 10013, "vid": 3438, "name": "COLD PRESSED FLAXSEED OIL", "price": 191, "img": "/web/image/product.template/10013/image_128"}, {"id": 10014, "vid": 3439, "name": "COLD PRESSED GROUNDNUT OIL", "price": 270, "img": "/web/image/product.template/10014/image_128"}, {"id": 10015, "vid": 3440, "name": "COLD PRESSED MUSTARD OIL", "price": 304, "img": "/web/image/product.template/10015/image_128"}, {"id": 10016, "vid": 3441, "name": "COLD PRESSED SESAME OIL", "price": 325, "img": "/web/image/product.template/10016/image_128"}], "39": [{"id": 9990, "vid": 3415, "name": "BARNYARD MILLET", "price": 133, "img": "/web/image/product.template/9990/image_128"}, {"id": 9996, "vid": 3421, "name": "BESAN FLOUR", "price": 170, "img": "/web/image/product.template/9996/image_128"}, {"id": 10049, "vid": 4247, "name": "JOWAR FLOUR", "price": 65, "img": "/web/image/product.template/10049/image_128"}, {"id": 10124, "vid": 3570, "name": "RAGI FLOUR", "price": 149, "img": "/web/image/product.template/10124/image_128"}], "40": [{"id": 10052, "vid": 3488, "name": "JOWAR PUTTUPODI", "price": 95, "img": "/web/image/product.template/10052/image_128"}, {"id": 8907, "vid": 2296, "name": "Ragi Puttupodi (500g)", "price": 95, "img": "/web/image/product.template/8907/image_128"}, {"id": 10948, "vid": 4431, "name": "Appam\u2013Idiyappam\u2013Pathiri Flour(Traditiona", "price": 69, "img": "/web/image/product.template/10948/image_128"}, {"id": 10944, "vid": 4427, "name": "Chemba Puttu Mix (Traditional)", "price": 78, "img": "/web/image/product.template/10944/image_128"}, {"id": 10943, "vid": 4426, "name": "Rice Puttu Mix", "price": 65, "img": "/web/image/product.template/10943/image_128"}], "41": [{"id": 10069, "vid": 3506, "name": "ASSAM GREEN TEA", "price": 180, "img": "/web/image/product.template/10069/image_128"}, {"id": 9959, "vid": 3378, "name": "CTC GOLD TEA POWDER", "price": 225, "img": "/web/image/product.template/9959/image_128"}, {"id": 10074, "vid": 3511, "name": "TEA MASALA", "price": 154, "img": "/web/image/product.template/10074/image_128"}], "42": [{"id": 10028, "vid": 3453, "name": "COCONUT SUGAR", "price": 223, "img": "/web/image/product.template/10028/image_128"}, {"id": 10042, "vid": 4601, "name": "HONEY", "price": 262, "img": "/web/image/product.template/10042/image_128"}, {"id": 10091, "vid": 3531, "name": "PALM SUGAR NATURAL", "price": 223, "img": "/web/image/product.template/10091/image_128"}], "43": [{"id": 9976, "vid": 3401, "name": "A2 PURE GIR COWGHEE", "price": 799, "img": "/web/image/product.template/9976/image_128"}, {"id": 9961, "vid": 3381, "name": "GHEE", "price": 550, "img": "/web/image/product.template/9961/image_128"}], "44": [{"id": 8787, "vid": 2176, "name": "Black Raisins Premium", "price": 181, "img": "/web/image/product.template/8787/image_128"}, {"id": 10123, "vid": 3569, "name": "PUMPKIN SEEDS", "price": 208, "img": "/web/image/product.template/10123/image_128"}], "45": [{"id": 10011, "vid": 3436, "name": "COLD PRESSED ALMOND OIL", "price": 570, "img": "/web/image/product.template/10011/image_128"}, {"id": 10060, "vid": 3496, "name": "KANNANKAYA BANANA DEHYDRATED SLICES", "price": 142, "img": "/web/image/product.template/10060/image_128"}]}, "prod_cat": {"3401": 31, "3506": 31, "3415": 31, "4238": 31, "3408": 36, "3419": 33, "4239": 33, "3421": 39, "4220": 39, "3520": 33, "3471": 33, "3472": 33, "3473": 33, "3575": 37, "4221": 37, "2176": 44, "3476": 33, "3477": 33, "3479": 33, "3478": 33, "3554": 33, "3555": 33, "3375": 34, "3565": 31, "3435": 33, "3453": 31, "3505": 31, "3436": 31, "3437": 38, "4216": 38, "3438": 38, "3439": 38, "4217": 38, "3440": 38, "4215": 38, "3441": 38, "3581": 38, "3442": 38, "4214": 38, "4803": 38, "3376": 38, "3378": 41, "3379": 41, "3558": 33, "3559": 33, "3459": 33, "3183": 31, "2275": 0, "3381": 31, "3468": 36, "3469": 33, "3538": 37, "3539": 37, "4229": 37, "3577": 37, "4222": 37, "4806": 37, "4601": 31, "4602": 31, "3384": 36, "3388": 32, "3389": 32, "3481": 32, "3390": 32, "3548": 34, "3483": 32, "3391": 32, "3392": 32, "3393": 32, "3486": 31, "4247": 31, "4248": 31, "3488": 31, "3496": 31, "3434": 34, "4228": 34, "3492": 31, "4244": 31, "4245": 31, "4518": 31, "4519": 31, "4818": 0, "3509": 37, "4804": 37, "4805": 37, "3502": 36, "3503": 34, "4236": 34, "3508": 31, "3514": 31, "3512": 31, "3515": 32, "3529": 33, "3399": 37, "3397": 37, "3398": 37, "3569": 31, "3568": 34, "3570": 31, "3545": 36, "2296": 31, "3544": 34, "4249": 34, "3494": 34, "3549": 34, "3550": 33, "3372": 34, "3467": 34, "3455": 37, "3457": 37, "3456": 37, "3511": 34, "3561": 37, "4224": 37, "4431": 40, "4427": 40, "3482": 32, "3513": 31, "3531": 31, "3533": 31, "3534": 31, "3537": 32, "4426": 40, "3551": 33}}
</script>
<script>
(function() {
/* (2026-09-26) Removed: setAttribute('data-add2cart-redirect', '0'). It forced Odoo's "go to cart" on every
Add to cart, overriding website.add_to_cart_action = 'stay'. Owner wants the shopper to stay on the page;
the cart-count observer below still shows the "added" toast and refreshes this drawer. */
window.addEventListener('PUBLIC-ROOT-READY', function(e) {
window.__msdPublicRoot = e.detail && e.detail.rootInstance;
});
function syncPartnerDetails(nameVal, phoneVal) {
fetch('/my/account').then(function(r) { return r.text(); }).then(function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var form = doc.querySelector('form[action="/my/account"]');
if (!form) return;
var fd = new FormData(form);
if (nameVal) { fd.set('name', nameVal); }
if (phoneVal) { fd.set('phone', phoneVal); }
fetch('/my/account', { method: 'POST', body: fd }).catch(function() {});
}).catch(function() {});
}
function clearSyntheticNameEmailFields(root) {
/* Odoo requires a non-empty name on the partner record, so an
earlier attempt to blank it out via the /my/account form likely
failed validation server-side and did nothing — the screenshot
confirmed the fields were still showing the raw phone digits.
This instead directly clears the VISIBLE input fields in the
currently-rendered checkout form whenever their value is exactly
the phone digits, so the shopper sees an empty field prompting
them to type a real name/email — no server round-trip needed. */
var digits = sessionStorage.getItem('msd_synthetic_phone_digits');
if (!digits) { return; }
var nameInput = root.querySelector('input[name="name"]');
var emailInput = root.querySelector('input[name="email"]');
[nameInput, emailInput].forEach(function(input) {
if (!input) { return; }
var valueDigits = input.value.split('@')[0].replace(/\D/g, '');
if (valueDigits && valueDigits === digits) { input.value = ''; }
});
}
function prefillAddressPhone() {
/* Explicitly carry the phone number the shopper just verified via
OTP into the checkout address form's phone field, instead of
leaving it to whatever the account/session happens to already
have — pre-filled but still editable, so the field is never
ambiguous or stale. This view is injected site-wide (not tied to
our old AJAX panel), so this runs on Odoo's own real checkout
page directly. */
var path = window.location.pathname;
if (path.indexOf('/shop/checkout') !== 0 && path.indexOf('/shop/address') !== 0) { return; }
var digits = sessionStorage.getItem('msd_synthetic_phone_digits') || sessionStorage.getItem('msd_pending_phone');
if (!digits) { return; }
digits = digits.replace(/\D/g, '');
if (!digits) { return; }
clearSyntheticNameEmailFields(document);
var phoneInput = document.querySelector('input[name="phone"]');
if (phoneInput && !phoneInput.value) {
phoneInput.value = digits;
phoneInput.dispatchEvent(new Event('input', { bubbles: true }));
phoneInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', prefillAddressPhone);
} else {
prefillAddressPhone();
}
var msdPostLogin = sessionStorage.getItem('msd_post_login_redirect') === '1' && !document.querySelector('.signup_open');
if (msdPostLogin) {
/* The real OTP widget appears to set name/email to the raw phone
digits as placeholder values when it creates a fresh account —
this happens before anything in this view runs, so it can't be
prevented from here. Remember the digits for the lifetime of this
tab so every checkout step load can clear them from the visible
form (see clearSyntheticNameEmailFields, called from
loadCheckoutStep below). */
var msdPendingPhone = sessionStorage.getItem('msd_pending_phone');
sessionStorage.removeItem('msd_pending_phone');
if (msdPendingPhone) {
sessionStorage.setItem('msd_synthetic_phone_digits', msdPendingPhone.replace(/\D/g, ''));
}
}
if (msdPostLogin) { sessionStorage.removeItem('msd_post_login_redirect'); }
var MILE1 = 2500;
var lastQty = null, drawerOpen = false;
var xdata = {};
try { xdata = JSON.parse(document.getElementById('msd-crosssell-data').textContent || '{}'); } catch (e) {}
function money(n) { return '₹' + Math.round(n).toLocaleString('en-IN'); }
function updateProgress(subtotal) {
var pct = Math.min(100, (subtotal / MILE1) * 100);
document.getElementById('msd-progress-fill').style.width = pct + '%';
var m1 = document.getElementById('msd-mile-1');
m1.textContent = (subtotal >= MILE1 ? '✓ ' : '') + 'Free delivery at ' + money(MILE1);
m1.classList.toggle('msd-hit', subtotal >= MILE1);
}
function renderCrossSell(prodIds) {
var picks = [];
var seen = {};
prodIds.forEach(function(pid) {
(xdata.accessory && xdata.accessory[pid] || []).forEach(function(item) {
if (!seen[item.id]) { seen[item.id] = 1; picks.push(item); }
});
});
if (picks.length < 4) {
prodIds.forEach(function(pid) {
var cid = xdata.prod_cat && xdata.prod_cat[pid];
(xdata.by_cat && xdata.by_cat[cid] || []).forEach(function(item) {
if (!seen[item.id] && picks.length < 8 && prodIds.indexOf(item.vid) === -1) {
seen[item.id] = 1; picks.push(item);
}
});
});
}
var panel = document.getElementById('msd-tab-panel');
panel.innerHTML = '';
picks.slice(0, 8).forEach(function(item) {
var card = document.createElement('div'); card.className = 'msd-xcard';
card.innerHTML = '<img src="' + item.img + '" alt=""/>' +
'<div class="msd-xname">' + item.name + '</div>' +
'<div class="msd-xprice">' + money(item.price) + '</div>' +
'<button type="button">Add</button>';
card.querySelector('button').onclick = function() {
addProduct(item.vid, function() { refreshDrawer(); });
};
panel.appendChild(card);
});
}
function addProduct(variantId, cb) {
var fd = { jsonrpc: '2.0', method: 'call', params: { product_id: variantId, add_qty: 1 } };
fetch('/shop/cart/update_json', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fd)
}).then(function() { if (cb) cb(); });
}
function wireCoupon() {
var form = document.querySelector('#msd-coupon-slot form[name="coupon_code"]');
if (!form) return;
var link = document.querySelector('#msd-coupon-slot .a-submit');
if (link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var fd = new FormData(form);
fetch(form.getAttribute('action'), { method: 'POST', body: fd })
.then(function() { refreshDrawer(); document.getElementById('msd-coupon-msg').textContent = ''; })
.catch(function() {});
});
}
}
/* Removed the "Use code MillerNew" apply-button handler — that
promotion is now automatic (applies once the cart reaches ₹2,500,
no code needed), so the dedicated coupon-suggestion button and its
handler no longer apply. #msd-coupon-slot / #msd-coupon-msg stay,
in case Odoo renders a generic coupon-code form there for any
other future promotion — wireCoupon() below still handles that. */
function splitNameSize(fullName) {
var m = fullName.match(/^(.*?)\s*\(([^)]+)\)\s*\.?\s*$/);
if (m) return { name: m[1].trim(), size: m[2].trim() };
var m2 = fullName.match(/^(.*?)\s+(\d+(?:\.\d+)?\s*(?:kg|gm|g|ml|l))\.?\s*$/i);
if (m2) return { name: m2[1].trim(), size: m2[2].trim() };
return { name: fullName.trim(), size: '' };
}
function wireLines() {
var host = document.getElementById('msd-lines');
host.addEventListener('click', function(e) {
var minus = e.target.closest('.msd-line-minus');
var plus = e.target.closest('.msd-line-plus');
var del = e.target.closest('.msd-line-del');
var line = e.target.closest('.msd-line');
if (!line) return;
var pid = parseInt(line.getAttribute('data-product-id'), 10);
var lid = parseInt(line.getAttribute('data-line-id'), 10);
var qty = parseInt(line.getAttribute('data-qty'), 10) || 0;
if (minus) { updateLineQty(pid, lid, qty - 1, line); }
else if (plus) { updateLineQty(pid, lid, qty + 1, line); }
else if (del) { updateLineQty(pid, lid, 0, line); }
});
}
wireLines();
var qtyPending = {}, qtyInFlight = {};
function updateLineQty(productId, lineId, newQty, cardEl) {
newQty = Math.max(0, newQty);
var key = productId + ':' + lineId;
if (cardEl) {
var span = cardEl.querySelector('.msd-line-qty span');
if (span) span.textContent = newQty;
cardEl.setAttribute('data-qty', newQty);
cardEl.style.opacity = newQty === 0 ? '.5' : '1';
}
qtyPending[key] = newQty;
if (qtyInFlight[key]) return;
sendQtyUpdate(productId, lineId, key);
}
function sendQtyUpdate(productId, lineId, key) {
qtyInFlight[key] = true;
var qty = qtyPending[key];
var params = { product_id: productId, set_qty: qty };
if (lineId) params.line_id = lineId;
fetch('/shop/cart/update_json', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params })
}).then(function(r) { return r.json(); }).then(function(res) {
qtyInFlight[key] = false;
var result = res && res.result;
if (result) {
applyCartFragments(result['website_sale.cart_lines'], result['website_sale.short_cart_summary']);
if (typeof result.cart_quantity === 'number') {
document.querySelectorAll('.my_cart_quantity').forEach(function(el) {
el.textContent = String(result.cart_quantity);
});
}
}
if (qtyPending[key] !== qty) { sendQtyUpdate(productId, lineId, key); }
}).catch(function() { qtyInFlight[key] = false; refreshDrawer(); });
}
function buildLineCard(tr) {
var input = tr.querySelector('.js_quantity');
if (!input) return null;
var pid = parseInt(input.getAttribute('data-product-id'), 10) || 0;
var lid = parseInt(input.getAttribute('data-line-id'), 10) || 0;
var qty = parseInt(input.value, 10) || 0;
var img = tr.querySelector('img');
var nameEl = tr.querySelector('.td-product_name strong');
var priceEl = tr.querySelector('.td-price .oe_currency_value');
var parts = splitNameSize(nameEl ? nameEl.textContent.trim() : '');
var price = priceEl ? (parseFloat(priceEl.textContent.replace(/[,\s\u200b\ufeff\u00a0]/g, '')) || 0) : 0;
var card = document.createElement('div');
card.className = 'msd-line';
card.setAttribute('data-product-id', pid);
card.setAttribute('data-line-id', lid);
card.setAttribute('data-qty', qty);
card.innerHTML =
'<img class="msd-line-img" src="' + (img ? img.src : '') + '" alt=""/>' +
'<div class="msd-line-info">' +
'<div class="msd-line-top">' +
'<div class="msd-line-name">' + parts.name + '</div>' +
'<div class="msd-line-price">' + (price < 0 ? '\u2212' + money(-price) : money(price)) + '</div>' +
'</div>' +
(parts.size ? '<div class="msd-line-size">' + parts.size + '</div>' : '') +
'<div class="msd-line-controls">' +
'<button type="button" class="msd-line-del" aria-label="Remove">🗑</button>' +
'<div class="msd-line-qty">' +
'<button type="button" class="msd-line-minus" aria-label="Decrease">−</button>' +
'<span>' + qty + '</span>' +
'<button type="button" class="msd-line-plus" aria-label="Increase">+</button>' +
'</div>' +
'</div>' +
'</div>';
var fullName = nameEl ? nameEl.textContent.trim() : '';
if (price < 0 || /\(KER-[A-Z0-9]{6}\)\s*$/.test(fullName)) { var qb = card.querySelector('.msd-line-qty'); if (qb) qb.style.display = 'none'; }
var gsrc = msdGiftSrc(fullName); if (gsrc) { var gim = card.querySelector('.msd-line-img'); if (gim) gim.src = gsrc; }
if (/^FREE /i.test(fullName)) msdAddBadge(card);
return card;
}
function buildLineCards(table) {
var frag = document.createDocumentFragment();
if (table) {
table.querySelectorAll('tbody > tr').forEach(function(tr) {
var card = buildLineCard(tr);
if (card) frag.appendChild(card);
});
}
return frag;
}
function applyCartFragments(linesHtml, summaryHtml) {
var linesHost = document.getElementById('msd-lines');
linesHost.innerHTML = '';
var prodIds = [];
var linesTable = null;
if (linesHtml) {
var tmp = document.createElement('div');
tmp.innerHTML = linesHtml;
linesTable = tmp.querySelector('table') || tmp;
}
var totalQty = 0;
if (linesTable) {
linesHost.appendChild(buildLineCards(linesTable));
linesHost.querySelectorAll('.msd-line').forEach(function(card) {
var pid = parseInt(card.getAttribute('data-product-id'), 10);
if (pid) prodIds.push(pid);
totalQty += parseInt(card.getAttribute('data-qty'), 10) || 0;
});
}
if (!linesHost.children.length) {
linesHost.innerHTML = '<p style="text-align:center;color:#999;padding:30px 0;">Your cart is empty.</p>';
}
/* Odoo's own top-navbar cart badge (.my_cart_quantity) only gets
refreshed by Odoo's native JS on certain events — after a
fresh login that restores a previously-saved cart, it can be
left showing 0 even though the real cart has items (confirmed:
opening our own drawer correctly shows the true contents, since
it fetches /shop/cart directly, same source used here). Keep
the native badge in sync with that same, reliably-correct data. */
document.querySelectorAll('.my_cart_quantity').forEach(function(el) {
el.textContent = String(totalQty);
});
var couponSlot = document.getElementById('msd-coupon-slot');
couponSlot.innerHTML = '';
var subtotal = 0, total = 0;
if (summaryHtml) {
var tmp2 = document.createElement('div');
tmp2.innerHTML = summaryHtml;
var untaxedRow = tmp2.querySelector('#order_total_untaxed .oe_currency_value');
if (untaxedRow) subtotal = parseFloat(untaxedRow.textContent.replace(/,/g, '')) || 0;
var totalRow = tmp2.querySelector('#order_total .oe_currency_value');
total = totalRow ? (parseFloat(totalRow.textContent.replace(/,/g, '')) || 0) : subtotal;
var form = tmp2.querySelector('form[name="coupon_code"]');
if (form) { couponSlot.appendChild(form); wireCoupon(); }
}
document.getElementById('msd-cart-total').textContent = money(total);
updateProgress(subtotal);
renderCrossSell(prodIds);
msdMaybeReconcile();
}
function refreshDrawer() {
fetch('/shop/cart').then(function(r) { return r.text(); }).then(function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var linesTable = doc.getElementById('cart_products');
var summary = doc.querySelector('.js_cart_summary');
applyCartFragments(linesTable ? linesTable.outerHTML : '', summary ? summary.outerHTML : '');
}).catch(function() {});
}
/* Spin-wheel coupons stay correct as the cart changes (v2).
- 10% coupon: re-priced to min(10% of the other lines as displayed, Rs 250) via the spin server (/reprice).
- Any coupon line (gift / 10% / delivery / amount) is forced to qty 1, and removed when no real item is left.
- Runs after drawer refreshes, on cart/checkout pages, and after ANY cart update on any page (resource-timing
observer), but only for visitors who applied a wheel coupon (localStorage sw_hyd_applied_* / sw_applied_*). */
var MSD_SPIN_API = 'https://spin.136-108-85-122.sslip.io';
var msdRec = false, msdIgnoreUntil = 0, msdSchedT = null, msdAgain = false;
function msdNum(t) { return parseFloat(String(t || '').replace(/[,\s ]/g, '')) || 0; }
function msdHasApplied() {
try { for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (/^sw_(hyd_)?applied_/.test(k)) return true; } } catch (e) {}
return false;
}
function msdGiftSrc(name) {
var n = String(name || '').toLowerCase(), id = 0;
if (n.indexOf('free ') !== 0) return '';
if (n.indexOf('coconut oil') > -1) id = 3437; else if (n.indexOf('millet rava') > -1) id = 4818; else if (n.indexOf('banana') > -1) id = 3496;
return id ? '/web/image/product.product/' + id + '/image_128' : '';
}
/* Gift-box badge on free gift lines (side cart + cart page). */
var MSD_GIFT_SVG = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="#fff"/><rect x="6" y="14" width="20" height="12" rx="2" fill="#b3261e"/><rect x="5" y="10" width="22" height="5" rx="1.5" fill="#d93a30"/><rect x="14.5" y="10" width="3" height="16" fill="#f2c14e"/><path d="M16 10c-1-4-6-5-6-2s4 2 6 2zm0 0c1-4 6-5 6-2s-4 2-6 2z" fill="none" stroke="#f2c14e" stroke-width="2"/></svg>');
function msdAddBadge(host) {
if (!host || host.querySelector('.msd-gift-badge')) return;
if (!document.getElementById('msd-gift-css')) {
var st = document.createElement('style'); st.id = 'msd-gift-css';
st.textContent = '.msd-gift-badge{position:absolute;top:4px;left:4px;width:28px;height:28px;border-radius:50%;box-shadow:0 1px 5px rgba(0,0,0,.35);background:#fff url("' + MSD_GIFT_SVG + '") center/22px no-repeat;z-index:2;pointer-events:none}';
document.head.appendChild(st);
}
host.style.position = 'relative';
var b = document.createElement('span'); b.className = 'msd-gift-badge'; b.setAttribute('aria-hidden', 'true'); b.title = 'Free gift';
host.appendChild(b);
}
function msdCartJson(params) {
return fetch('/shop/cart/update_json', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params }) }).then(function(r) { return r.json(); });
}
function msdReconcile() {
if (msdRec) return Promise.resolve(false);
msdRec = true;
return fetch('/shop/cart').then(function(r) { return r.text(); }).then(function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var coupons = [], pct = null, base = 0;
doc.querySelectorAll('#cart_products tbody > tr').forEach(function(tr) {
var input = tr.querySelector('.js_quantity'); if (!input) return;
var nameEl = tr.querySelector('.td-product_name strong');
var name = nameEl ? nameEl.textContent.trim() : '';
var priceEl = tr.querySelector('.td-price .oe_currency_value');
var price = priceEl ? msdNum(priceEl.textContent) : 0;
var qty = parseFloat(input.value) || 0;
var m = /\((KER-[A-Z0-9]{6})\)\s*$/.exec(name);
var line = { code: m ? m[1] : '', pid: parseInt(input.getAttribute('data-product-id'), 10), lid: parseInt(input.getAttribute('data-line-id'), 10), price: price, qty: qty, name: name };
if (m) { coupons.push(line); if (/Spin discount 10% off/.test(name)) pct = line; }
else if (price > 0) base += price * qty;
});
try { if (coupons.length) localStorage.setItem('msd_coupon_seen', '1'); else localStorage.removeItem('msd_coupon_seen'); } catch (e) {}
if (!coupons.length) return false;
var changed = false, chain = Promise.resolve();
if (base <= 0) { // nothing real left: coupons without items are removed
coupons.forEach(function(c) { chain = chain.then(function() { changed = true; return msdCartJson({ product_id: c.pid, line_id: c.lid, set_qty: 0 }); }); });
return chain.then(function() { return changed; });
}
coupons.forEach(function(c) { // a coupon line is always exactly 1
if (c.qty > 1) chain = chain.then(function() { changed = true; return msdCartJson({ product_id: c.pid, line_id: c.lid, set_qty: 1 }); });
});
if (pct) {
var want = Math.min(Math.round(base * 10) / 100, 250);
if (Math.abs(want - (-pct.price)) >= 0.01) chain = chain.then(function() {
return fetch(MSD_SPIN_API + '/reprice', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: pct.code, cartTotal: base }) }).then(function(r) { return r.json(); }).then(function(j) {
if (!j || !j.ok || !j.variantId) return;
changed = true;
return msdCartJson({ product_id: pct.pid, line_id: pct.lid, set_qty: 0 }).then(function() {
return msdCartJson({ product_id: j.variantId, add_qty: 1 });
});
});
});
}
return chain.then(function() { return changed; });
}).catch(function() { return false; }).then(function(changed) { msdRec = false; msdIgnoreUntil = Date.now() + 600; if (msdAgain) { msdAgain = false; setTimeout(msdSchedule, 900); } return changed; });
}
function msdMaybeReconcile() {
msdRenderWon();
var has = false;
document.querySelectorAll('#msd-lines .msd-line-name').forEach(function(n) { if (/Spin discount|^FREE /.test(n.textContent)) has = true; });
if (has && !msdRec) msdReconcile().then(function(changed) { if (changed) refreshDrawer(); });
}
function msdSchedule() {
if (!msdHasApplied() && !(function() { try { return localStorage.getItem('msd_coupon_seen'); } catch (e) { return null; } })()) return;
clearTimeout(msdSchedT);
msdSchedT = setTimeout(function() {
msdReconcile().then(function(changed) {
if (!changed) return;
if (/^\/shop\/(cart|checkout|payment|address)/.test(location.pathname)) location.reload(); else refreshDrawer();
});
}, 700);
}
if (window.PerformanceObserver) {
try { new PerformanceObserver(function(list) {
list.getEntries().forEach(function(e) {
if (/\/shop\/cart\/(update_json|update|add)/.test(e.name)) { if (msdRec || Date.now() < msdIgnoreUntil) msdAgain = true; else msdSchedule(); }
});
}).observe({ type: 'resource' }); } catch (e) {}
}
if (msdHasApplied()) setTimeout(msdSchedule, 1500);
function msdFixCartImages() {
document.querySelectorAll('#cart_products tbody > tr').forEach(function(tr) {
var n = tr.querySelector('.td-product_name strong'), im = tr.querySelector('img'), s = n ? msdGiftSrc(n.textContent.trim()) : '';
if (s && im && im.getAttribute('src') !== s) { im.removeAttribute('srcset'); im.setAttribute('src', s); }
if (n && /^FREE /i.test(n.textContent.trim())) msdAddBadge(tr.querySelector('.td-img span') || tr.querySelector('.td-img'));
});
}
if (/^\/shop\/(cart|checkout|payment|address)/.test(location.pathname)) {
var msdRecTimer = null;
var msdRecPage = function() {
msdFixCartImages();
if (!/Spin discount|FREE /.test(document.body.textContent)) return;
msdReconcile().then(function(changed) { if (changed) location.reload(); });
};
document.addEventListener('DOMContentLoaded', msdRecPage);
if (document.readyState !== 'loading') msdRecPage();
var msdCartTable = document.getElementById('cart_products');
if (msdCartTable && window.MutationObserver) new MutationObserver(function() {
msdFixCartImages(); clearTimeout(msdRecTimer); msdRecTimer = setTimeout(msdRecPage, 700);
}).observe(msdCartTable, { childList: true, subtree: true });
}
/* "You won" box under Frequently Bought Together in the side cart. The wheel saves the win in
localStorage 'sw_hyd_won' = {code, kind, label, prize, applied}; this box keeps the code visible and lets the
shopper apply it later, once there is something in the cart. Applies by code (POST /apply {code, cartTotal}). */
function msdWonRead() { try { return JSON.parse(localStorage.getItem('sw_hyd_won') || 'null'); } catch (e) { return null; } }
function msdWonSave(w) { try { if (w) localStorage.setItem('sw_hyd_won', JSON.stringify(w)); else localStorage.removeItem('sw_hyd_won'); } catch (e) {} }
function msdWonCss() {
if (document.getElementById('msd-won-css')) return;
var st = document.createElement('style'); st.id = 'msd-won-css';
st.textContent = '#msd-won:empty{display:none}#msd-won{margin:14px 0 4px}' +
'.msd-won-box{border:1px dashed #1f6f2a;background:#f4fbf4;border-radius:12px;padding:12px 14px}' +
'.msd-won-top{display:flex;gap:10px;align-items:center}.msd-won-ico{flex:0 0 30px;width:30px;height:30px;border-radius:50%;background:#fff url("' + MSD_GIFT_SVG + '") center/22px no-repeat;box-shadow:0 1px 4px rgba(0,0,0,.25)}' +
'.msd-won-t{font-weight:600;font-size:14px;line-height:1.25}.msd-won-sub{font-size:12px;color:#555;margin-top:2px}' +
'.msd-won-code{display:inline-block;margin:8px 0 0;padding:5px 10px;background:#fff;border:1px solid #c9d8c9;border-radius:8px;font-family:ui-monospace,Menlo,monospace;letter-spacing:.06em;font-size:14px}' +
'.msd-won-row{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}.msd-won-row button{min-height:40px;border-radius:999px;padding:0 16px;font:inherit;font-size:14px;cursor:pointer;border:1px solid #1f6f2a;background:#fff;color:#1f6f2a}' +
'.msd-won-row button.pri{background:#1f6f2a;color:#fff}.msd-won-row button[disabled]{opacity:.6;cursor:default}.msd-won-msg{font-size:13px;margin-top:8px;color:#8a4b00}.msd-won-ok{font-size:13px;margin-top:8px;color:#1f6f2a;font-weight:600}';
document.head.appendChild(st);
}
function msdWonBase() { // displayed prices of the real items, same rule as msdReconcile
return fetch('/shop/cart').then(function(r) { return r.text(); }).then(function(html) {
var doc = new DOMParser().parseFromString(html, 'text/html'), base = 0;
doc.querySelectorAll('#cart_products tbody > tr').forEach(function(tr) {
var input = tr.querySelector('.js_quantity'); if (!input) return;
var nameEl = tr.querySelector('.td-product_name strong'), name = nameEl ? nameEl.textContent.trim() : '';
var priceEl = tr.querySelector('.td-price .oe_currency_value'), price = priceEl ? msdNum(priceEl.textContent) : 0;
if (!/\(KER-[A-Z0-9]{6}\)\s*$/.test(name) && price > 0) base += price * (parseFloat(input.value) || 0);
});
return Math.round(base * 100) / 100;
});
}
function msdEsc(t) { return String(t == null ? '' : t).replace(/[&<>"']/g, function(c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }
function msdRenderWon() {
var host = document.getElementById('msd-won'); if (!host) return;
var w = msdWonRead();
if (!w || !w.code) { host.innerHTML = ''; return; }
msdWonCss();
var inCart = (document.getElementById('msd-lines') || {}).textContent || '';
inCart = inCart.indexOf(w.code) > -1;
host.innerHTML = '<div class="msd-won-box"><div class="msd-won-top"><span class="msd-won-ico" aria-hidden="true"></span><div><div class="msd-won-t">You won ' + msdEsc(String(w.label || 'a reward').replace(/^free /i, 'free ')) + '</div>' +
'<div class="msd-won-sub">' + (inCart ? 'Added to your cart.' : 'Not in your cart yet. Apply it any time.') + '</div></div></div>' +
'<span class="msd-won-code" id="msd-won-code">' + msdEsc(w.code) + '</span>' +
'<div class="msd-won-row"><button type="button" id="msd-won-copy">Copy code</button>' + (inCart ? '' : '<button type="button" class="pri" id="msd-won-apply">Apply to cart</button>') + '</div>' +
'<div id="msd-won-msg" aria-live="polite"></div></div>';
var cp = document.getElementById('msd-won-copy');
if (cp) cp.addEventListener('click', function() {
var done = function(ok) { cp.textContent = ok ? 'Copied' : 'Press and hold the code'; setTimeout(function() { cp.textContent = 'Copy code'; }, 1800); };
try { navigator.clipboard.writeText(w.code).then(function() { done(true); }, function() { done(false); }); } catch (e) { done(false); }
});
var ap = document.getElementById('msd-won-apply');
if (ap) ap.addEventListener('click', function() { msdWonApply(w, ap); });
}
function msdWonMsg(t, ok) { var m = document.getElementById('msd-won-msg'); if (m) { m.className = ok ? 'msd-won-ok' : 'msd-won-msg'; m.textContent = t || ''; } }
function msdWonApply(w, btn) {
btn.disabled = true; var old = btn.textContent; btn.textContent = 'Adding…'; msdWonMsg('');
var fail = function(t) { btn.disabled = false; btn.textContent = old; msdWonMsg(t, false); };
msdWonBase().then(function(base) {
if (!(base > 0)) return fail('Add items to your cart first, then tap Apply to cart.');
return fetch(MSD_SPIN_API + '/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: w.code, cartTotal: base }) })
.then(function(r) { return r.json(); }).then(function(j) {
if (!j || !j.ok) {
if (j && j.error === 'used') { msdWonSave(null); msdRenderWon(); return; }
return fail(j && j.error === 'too_many' ? 'Too many attempts. Please wait a while and try again.' : 'Could not add the coupon. Please try again.');
}
return msdCartJson({ product_id: j.variantId, add_qty: 1 }).then(function(c) {
if (c && c.error) return fail('Could not add to cart. Please tap Apply again.');
w.applied = true; msdWonSave(w); try { localStorage.setItem('sw_hyd_applied_' + w.code, '1'); } catch (e) {}
refreshDrawer();
});
});
}).catch(function() { fail('Network problem. Please try again.'); });
}
function openDrawer() {
document.getElementById('msd-overlay').classList.add('show');
document.getElementById('msd-drawer').classList.add('show');
drawerOpen = true;
refreshDrawer();
}
function closeDrawer() {
document.getElementById('msd-overlay').classList.remove('show');
document.getElementById('msd-drawer').classList.remove('show');
drawerOpen = false;
}
document.getElementById('msd-close').addEventListener('click', closeDrawer);
document.getElementById('msd-overlay').addEventListener('click', closeDrawer);
window.__msdOpenDrawer = openDrawer;
document.getElementById('msd-checkout-btn').addEventListener('click', function() {
if (!document.getElementById('msd-lines').querySelector('.msd-line')) { return; }
if (document.querySelector('.login_dialog')) { return; }
var loginTrigger = document.querySelector('.signup_open');
/* "Login as guest" (quick-signup popup, localStorage msq_guest) = straight to checkout, no OTP loop.
Needs website.account_on_checkout = optional. Name + phone are prefilled on the address form. */
var msqGuest = null;
try { msqGuest = JSON.parse(localStorage.getItem('msq_guest') || 'null'); } catch (e) {}
if (msqGuest && msqGuest.name && msqGuest.phone) {
sessionStorage.setItem('msd_pending_phone', msqGuest.phone);
closeDrawer();
openCheckoutPanel('/shop/checkout');
} else if (loginTrigger) {
sessionStorage.setItem('msd_post_login_redirect', '1');
loginTrigger.click();
} else {
closeDrawer();
openCheckoutPanel('/shop/checkout');
}
});
var msdCheckoutObserver = null;
function closeCheckoutPanel() {
document.getElementById('msd-checkout-overlay').classList.remove('show');
document.getElementById('msd-checkout-panel').classList.remove('show');
var wrapwrap = document.getElementById('wrapwrap');
if (wrapwrap) { wrapwrap.style.removeProperty('visibility'); }
if (msdCheckoutObserver) { msdCheckoutObserver.disconnect(); msdCheckoutObserver = null; }
}
document.getElementById('msd-co-close').addEventListener('click', closeCheckoutPanel);
document.getElementById('msd-checkout-overlay').addEventListener('click', closeCheckoutPanel);
function extractSaleContent(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var el = doc.querySelector('.oe_website_sale');
return el ? el.outerHTML : html;
}
var msdCoLastUrl = '/shop/checkout';
function updateCoLineQty(productId, lineId, newQty) {
var params = { product_id: productId, set_qty: Math.max(0, newQty) };
if (lineId) params.line_id = lineId;
fetch('/shop/cart/update_json', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: params })
}).then(function() { loadCheckoutStep(msdCoLastUrl, 'GET'); }).catch(function() {});
}
function repositionOrderTotal(body) {
var totalAnchor = body.querySelector('#order_total') || body.querySelector('#order_total_untaxed');
if (!totalAnchor) { return; }
/* Screenshot showed the Subtotal/Taxes/Total box appearing right at
the top, unchanged — walking up to "the nearest direct child of
body" was too coarse and likely grabbed a wrapper that contains
BOTH the summary AND the address form together, so "moving" it
was a no-op. Target the small table itself directly instead. */
var totalTable = totalAnchor.closest('table');
var totalBlock = totalTable;
if (!totalBlock) {
totalBlock = totalAnchor;
while (totalBlock.parentElement && totalBlock.parentElement !== body) {
totalBlock = totalBlock.parentElement;
}
}
if (totalBlock && totalBlock.parentElement && totalBlock !== body.lastElementChild) {
body.appendChild(totalBlock);
}
}
function repositionBillingShippingNotice(body) {
var candidates = body.querySelectorAll('.alert, [role="alert"]');
var notice = null;
for (var i = 0; i < candidates.length; i++) {
if (/billing and shipping addresses at the same time/i.test(candidates[i].textContent || '')) {
notice = candidates[i];
break;
}
}
if (!notice) { return; }
/* Call this AFTER repositionOrderTotal in the same pass so the
notice lands after the total block, not before it. */
if (notice !== body.lastElementChild) {
body.appendChild(notice);
}
}
function addDeliveryCalcNotice(body) {
/* Only on the Review Order step, not Address or Confirm Order.
The URL doesn't reliably change between checkout wizard steps
(that's why the earlier msdCoLastUrl==='/shop/cart' check never
matched), so key off something that's actually distinctive to
this step instead: our own .msd-co-lines wrapper, which only
gets created when the real cart-lines table (table#cart_products)
was present in the loaded content — i.e. only on Review Order. */
if (!body.querySelector('.msd-co-lines')) { return; }
if (body.querySelector('#msd-delivery-notice')) { return; }
var totalAnchor = body.querySelector('#order_total') || body.querySelector('#order_total_untaxed');
if (!totalAnchor) { return; }
var totalBlock = totalAnchor.closest('table') || totalAnchor;
var notice = document.createElement('p');
notice.id = 'msd-delivery-notice';
notice.style.cssText = 'font-size:11px;color:#888;text-align:center;margin:8px 0 0;';
notice.textContent = 'Delivery charges will be calculated at the payment page.';
if (totalBlock.parentElement) {
totalBlock.parentElement.insertBefore(notice, totalBlock.nextSibling);
}
}
function defaultCountryToIndia(root) {
var select = root.querySelector('select[name="country_id"]');
if (!select || select.value) { return; }
var options = select.querySelectorAll('option');
for (var i = 0; i < options.length; i++) {
if (/^india$/i.test((options[i].textContent || '').trim())) {
select.value = options[i].value;
select.dispatchEvent(new Event('change', { bubbles: true }));
break;
}
}
}
/* Reverted: attempts to force-enable the Pay Now button and open its
form in a new tab (name="o_payment_submit_button", form path
confirmed via inspect element as
#payment_method > div > form > div.float-end.mt-2 > button)
didn't get Razorpay actually loading — reverted per request rather
than continue guessing blind on a live payment flow. Kept here as
a note for whoever picks this back up: the button is disabled by
default in Odoo's static HTML and needs its own payment-form JS
to enable it and open Razorpay; that JS was not reliably running
against this AJAX-inserted content, but forcing the button enabled
and/or opening a new tab was not sufficient on its own to fix it. */
function reapplyCheckoutFixups(body) {
repositionOrderTotal(body);
addDeliveryCalcNotice(body);
repositionBillingShippingNotice(body);
clearSyntheticNameEmailFields(body);
defaultCountryToIndia(body);
}
function loadCheckoutStep(url, method, formData) {
var body = document.getElementById('msd-co-body');
body.innerHTML = '<div id="msd-co-loading">Loading…</div>';
var opts = { method: method || 'GET' };
if (formData) { opts.body = formData; }
fetch(url, opts).then(function(r) {
var finalUrl = new URL(r.url, window.location.href);
if (finalUrl.origin !== window.location.origin) {
window.location.href = r.url;
return null;
}
msdCoLastUrl = finalUrl.pathname;
return r.text();
}).then(function(html) {
if (html === null) { return; }
body.innerHTML = extractSaleContent(html);
var cartTable = body.querySelector('table#cart_products');
if (cartTable) {
var cards = document.createElement('div');
cards.className = 'msd-co-lines';
cards.appendChild(buildLineCards(cartTable));
cartTable.replaceWith(cards);
}
reapplyCheckoutFixups(body);
if (window.__msdPublicRoot) {
try { window.__msdPublicRoot.attachTo(body); } catch (attachErr) {}
}
/* The checkout content can be hydrated asynchronously by Odoo's own
JS (via __msdPublicRoot.attachTo, or later re-renders triggered
by the widget itself) — a fixed set of retry delays could still
miss something that appears later, so watch continuously instead
for as long as this checkout step is on screen. */
if (msdCheckoutObserver) { msdCheckoutObserver.disconnect(); }
msdCheckoutObserver = new MutationObserver(function() { reapplyCheckoutFixups(body); });
msdCheckoutObserver.observe(body, { childList: true, subtree: true });
}).catch(function() {
body.innerHTML = '<p style="text-align:center;color:#999;padding:40px 0;">Something went wrong loading checkout. <a href="/shop/checkout">Open checkout page</a></p>';
});
}
function openCheckoutPanel(url) {
/* STOPGAP: the custom in-panel checkout (AJAX-loaded via
loadCheckoutStep) does not reliably get real customers to a
working Razorpay payment — the payment step's own JS does not
run correctly against our AJAX-inserted content, and several
targeted attempts to fix that did not resolve it. Real payment
is the priority, so checkout now bypasses this custom panel
entirely with a real page navigation to Odoo's own, unmodified
checkout pages, where Razorpay is known to be correctly
configured. Cart browsing (the drawer) is untouched — only the
checkout process itself now leaves this panel. */
window.location.href = url;
}
window.__msdOpenCheckout = openCheckoutPanel;
document.getElementById('msd-co-body').addEventListener('click', function(e) {
/* Payment provider buttons (e.g. Razorpay's "Pay Now") need their
own native JS to run untouched — Razorpay opens its own modal via
a script, it isn't a simple link navigation or form post. The
submit handler below already excludes .o_payment_form for this
same reason; this click handler was missing the same exclusion,
and one of the href patterns intercepted further down
(/shop/payment) meant a payment button click could get
preventDefault()'d before Razorpay's own handler ever got to run
— matching "clicking Pay Now does nothing". */
if (e.target.closest('.o_payment_form')) { return; }
var coLine = e.target.closest('.msd-line');
if (coLine) {
var pid = parseInt(coLine.getAttribute('data-product-id'), 10);
var lid = parseInt(coLine.getAttribute('data-line-id'), 10);
var qty = parseInt(coLine.getAttribute('data-qty'), 10) || 0;
if (e.target.closest('.msd-line-minus')) { e.preventDefault(); updateCoLineQty(pid, lid, qty - 1); return; }
if (e.target.closest('.msd-line-plus')) { e.preventDefault(); updateCoLineQty(pid, lid, qty + 1); return; }
if (e.target.closest('.msd-line-del')) { e.preventDefault(); updateCoLineQty(pid, lid, 0); return; }
}
var submitLink = e.target.closest('a.a-submit');
if (submitLink) {
var form = submitLink.closest('form');
if (form) {
e.preventDefault();
loadCheckoutStep(form.getAttribute('action') || window.location.pathname, form.method || 'POST', new FormData(form));
return;
}
}
var addrCard = e.target.closest('.one_kanban .card');
if (addrCard && !e.target.closest('.js_edit_address')) {
var kanban = addrCard.closest('.one_kanban');
var hiddenForm = kanban ? kanban.querySelector('form') : null;
if (hiddenForm) {
e.preventDefault();
loadCheckoutStep(hiddenForm.getAttribute('action') || '/shop/checkout', hiddenForm.method || 'POST', new FormData(hiddenForm));
return;
}
}
var link = e.target.closest('a[href]');
if (link) {
/* This used to check the raw href ATTRIBUTE, requiring it to be a
relative path starting with e.g. "/shop/checkout" — if Odoo
rendered a particular button's href as a fully-qualified
absolute URL instead (https://www.millerstores.com/shop/...),
that check silently failed to match and the click fell through
to a normal full-page navigation, breaking out of this panel.
Resolve to just the pathname first so both forms match. */
var path;
try { path = new URL(link.href, window.location.href).pathname; }
catch (err) { path = link.getAttribute('href') || ''; }
if (path.indexOf('/shop/checkout') === 0 || path.indexOf('/shop/address') === 0 ||
path.indexOf('/shop/confirm_order') === 0 || path.indexOf('/shop/payment') === 0 ||
path === '/shop/cart') {
e.preventDefault();
loadCheckoutStep(link.href, 'GET');
}
}
});
document.getElementById('msd-co-body').addEventListener('submit', function(e) {
var form = e.target;
if (form.tagName !== 'FORM') { return; }
if (form.classList.contains('o_payment_form')) { return; }
e.preventDefault();
var action = form.getAttribute('action') || window.location.pathname;
var fd = new FormData(form);
if (action.indexOf('/shop/address') === 0) {
var newName = (fd.get('name') || '').toString().trim();
var newPhone = (fd.get('phone') || '').toString().trim();
if (newName || newPhone) { syncPartnerDetails(newName || null, newPhone || null); }
}
loadCheckoutStep(action, form.method || 'POST', fd);
});
if (msdPostLogin) {
var msdWrapwrap = document.getElementById('wrapwrap');
if (msdWrapwrap) { msdWrapwrap.style.setProperty('visibility', 'hidden', 'important'); }
openCheckoutPanel('/shop/checkout');
/* Proactively sync the drawer state (and the native cart badge via
applyCartFragments) right after login, rather than waiting for
the shopper to manually open the drawer — this is what was
leaving the top-navbar badge stuck at 0 after a fresh login
restored a previously-saved cart. */
refreshDrawer();
}
if (window.location.pathname === '/shop/confirmation') {
var confirmContent = document.querySelector('.oe_website_sale');
if (confirmContent) {
document.getElementById('msd-co-title').textContent = 'Order Confirmed';
document.getElementById('msd-co-body').innerHTML = confirmContent.outerHTML;
document.getElementById('msd-checkout-overlay').classList.add('show');
document.getElementById('msd-checkout-panel').classList.add('show');
var fullText = (confirmContent.textContent || '').replace(/\s+/g, ' ').trim();
var orderMatch = fullText.match(/\b(S\d{4,})\b/);
msdSubmitFunnelEvent({
x_session_id: msdFunnelSessionId(),
x_event_type: 'order_placed',
x_cart_json: msdCurrentCartSnapshot(),
x_delivery_address_text: fullText.slice(0, 4000),
x_order_ref: orderMatch ? orderMatch[1] : null
});
}
}
var MSD_EXIT_REASONS = [
'Facing problems at checkout', 'Changed my mind', 'Got a better discount somewhere else',
'Not able to apply discounts', 'Delivery time longer than expected', 'Need to modify items in my cart',
'Preferred payment method not available', 'Budget Constraints', 'Others'
];
function buildExitSurvey() {
var existing = document.getElementById('msd-exit-survey');
if (existing) { return existing; }
var el = document.createElement('div');
el.id = 'msd-exit-survey';
el.innerHTML =
'<div id="msd-es-head"><h4>Why are you leaving?</h4><button type="button" id="msd-es-close" aria-label="Close">×</button></div>' +
'<div id="msd-es-list">' +
MSD_EXIT_REASONS.map(function(r) {
return '<label class="msd-es-opt"><input type="radio" name="msd_es_reason" value="' + r + '"/>' + r + '</label>';
}).join('') +
'</div>' +
'<div id="msd-es-other-wrap"><label>Reason (Optional)</label><textarea id="msd-es-other-text" placeholder="Tell us more..."></textarea></div>' +
'<div id="msd-es-footer">' +
'<button type="button" id="msd-es-submit">Submit Feedback</button>' +
'<button type="button" id="msd-es-skip">Skip & Exit</button>' +
'</div>';
document.body.appendChild(el);
el.addEventListener('change', function(e) {
if (e.target && e.target.name === 'msd_es_reason') {
document.getElementById('msd-es-other-wrap').classList.toggle('show', e.target.value === 'Others');
}
});
document.getElementById('msd-es-close').addEventListener('click', hideExitSurvey);
document.getElementById('msd-es-skip').addEventListener('click', hideExitSurvey);
document.getElementById('msd-es-submit').addEventListener('click', function() {
var picked = el.querySelector('input[name="msd_es_reason"]:checked');
var reason = picked ? picked.value : null;
var detail = document.getElementById('msd-es-other-text').value.trim();
if (reason) {
try {
sessionStorage.setItem('msd_exit_reason', JSON.stringify({ reason: reason, detail: detail, ts: Date.now() }));
} catch (err) {}
/* Previously this only ever saved to the browser's own
sessionStorage — never actually sent anywhere, so none of
this feedback was ever visible on /onlinecrm. Send it through
the same funnel-logging mechanism as everything else. */
msdSubmitFunnelEvent({
x_session_id: msdFunnelSessionId(),
x_event_type: 'exit_feedback',
x_cart_json: JSON.stringify({ reason: reason, detail: detail })
});
}
hideExitSurvey();
});
return el;
}
function showExitSurvey() {
buildExitSurvey().classList.add('show');
}
function hideExitSurvey() {
var el = document.getElementById('msd-exit-survey');
if (el) { el.classList.remove('show'); }
}
function msdFunnelSessionId() {
var sid = sessionStorage.getItem('msd_funnel_id');
if (!sid) {
sid = 'msd-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
sessionStorage.setItem('msd_funnel_id', sid);
}
return sid;
}
function msdCurrentCartSnapshot() {
var lines = [];
document.querySelectorAll('#msd-body .msd-line').forEach(function(row) {
var name = row.querySelector('.msd-line-name');
var price = row.querySelector('.msd-line-price');
var qty = row.querySelector('.msd-line-qty span');
lines.push({
name: name ? name.textContent.trim() : '',
price: price ? price.textContent.trim() : '',
qty: qty ? qty.textContent.trim() : ''
});
});
return JSON.stringify(lines);
}
function msdFunnelFormData(fields) {
var fd = new FormData();
for (var k in fields) {
if (fields[k] !== null && fields[k] !== undefined) { fd.append(k, fields[k]); }
}
var tokenInput = document.querySelector('input[name="csrf_token"]');
var token = (window.odoo && window.odoo.csrf_token) || (tokenInput && tokenInput.value);
if (token) { fd.append('csrf_token', token); }
return fd;
}
function msdSubmitFunnelEvent(fields) {
try {
fetch('/website/form/x_msd_checkout_log', {
method: 'POST', body: msdFunnelFormData(fields), credentials: 'same-origin'
}).catch(function() {});
} catch (e) {}
}
function msdSubmitFunnelEventBeacon(fields) {
try {
if (navigator.sendBeacon) {
navigator.sendBeacon('/website/form/x_msd_checkout_log', msdFunnelFormData(fields));
} else {
msdSubmitFunnelEvent(fields);
}
} catch (e) {}
}
function watchOtpOutcome(dlg) {
if (dlg.dataset.msdOutcomeWatched) { return; }
dlg.dataset.msdOutcomeWatched = '1';
var logged = false;
function checkFail() {
if (logged) { return; }
var text = dlg.textContent || '';
if (/invalid|incorrect|wrong otp|expired/i.test(text)) {
logged = true;
msdSubmitFunnelEvent({ x_session_id: msdFunnelSessionId(), x_event_type: 'otp_failed' });
}
}
new MutationObserver(checkFail).observe(dlg, { childList: true, subtree: true, characterData: true });
window.addEventListener('beforeunload', function() {
var shell = document.getElementById('msd-otp-shell');
if (!logged && shell && shell.classList.contains('show')) {
msdSubmitFunnelEventBeacon({ x_session_id: msdFunnelSessionId(), x_event_type: 'otp_success' });
}
});
}
function relocateLoginDialog(dlg) {
if (dlg.dataset.msdRelocated) { return; }
dlg.dataset.msdRelocated = '1';
var narrow = window.innerWidth <= 767;
/* NOTE: moving the dialog to <body> fixed the position but broke
interactivity (typing/clicking) — almost certainly because some
focus-trap or event-delegation logic in the real widget tracks the
dialog's ORIGINAL place in the DOM tree, and got confused when that
changed. This version moves NOTHING. Instead it directly neutralizes
transform/filter/perspective/will-change on every ancestor between
the dialog and <body> — any one of those properties creates a new
"containing block" that makes position:fixed resolve against THAT
ancestor's box instead of the real viewport, which is almost
certainly why every earlier in-place attempt landed at the top
instead of the bottom. Removing them restores fixed's normal
viewport-relative behavior without touching DOM structure at all. */
var modalDialog = dlg.closest('.modal-dialog');
var modalEl = dlg.closest('.modal');
var content = dlg.closest('.modal-content') || modalDialog || dlg;
var box = modalDialog || content;
var node = box.parentElement;
while (node && node !== document.body) {
node.style.setProperty('transform', 'none', 'important');
node.style.setProperty('filter', 'none', 'important');
node.style.setProperty('perspective', 'none', 'important');
node.style.setProperty('will-change', 'auto', 'important');
node = node.parentElement;
}
function applyPositioning() {
if (modalEl) { modalEl.style.setProperty('z-index', '2200', 'important'); }
box.style.setProperty('position', 'fixed', 'important');
box.style.setProperty('top', 'auto', 'important');
box.style.setProperty('left', narrow ? '0' : 'auto', 'important');
box.style.setProperty('right', narrow ? '0' : '20px', 'important');
box.style.setProperty('bottom', narrow ? '0' : '20px', 'important');
box.style.setProperty('margin', '0', 'important');
box.style.setProperty('transform', 'none', 'important');
box.style.setProperty('max-width', narrow ? '420px' : '400px', 'important');
box.style.setProperty('width', narrow ? '100%' : '400px', 'important');
box.style.setProperty('z-index', '2201', 'important');
}
applyPositioning();
[50, 150, 300, 600, 1200, 2000].forEach(function(ms) { setTimeout(applyPositioning, ms); });
content.style.setProperty('border-radius', narrow ? '18px 18px 0 0' : '18px', 'important');
content.style.setProperty('overflow', 'hidden', 'important');
var closeBtn = dlg.querySelector('.close_modal');
if (closeBtn && !closeBtn.dataset.msdCloseWired) {
closeBtn.dataset.msdCloseWired = '1';
closeBtn.addEventListener('click', function() {
showExitSurvey();
});
}
}
function reskinLoginDialog(dlg) {
relocateLoginDialog(dlg);
watchOtpOutcome(dlg);
var h4 = dlg.querySelector('#mobileInputSection h4');
if (h4 && h4.textContent.indexOf('Almost there') === -1) {
h4.textContent = 'Almost there…';
var sub = document.createElement('p');
sub.className = 'msd-tb-subtitle';
sub.textContent = 'Verify your phone number to place an order';
h4.insertAdjacentElement('afterend', sub);
}
var input = dlg.querySelector('.mobile_number');
if (input && !input.closest('.msd-phone-wrap')) {
var wrap = document.createElement('div');
wrap.className = 'msd-phone-wrap';
var prefix = document.createElement('span');
prefix.className = 'msd-cc-prefix';
prefix.innerHTML = '🇮🇳 <span class="msd-cc-chevron">▾</span> +91';
input.parentNode.insertBefore(wrap, input);
wrap.appendChild(prefix);
wrap.appendChild(input);
}
if (input && !input.dataset.msdPhoneCapture) {
input.dataset.msdPhoneCapture = '1';
input.addEventListener('input', function() {
var digits = input.value.replace(/\D/g, '');
if (digits.length === 10) {
sessionStorage.setItem('msd_pending_phone', digits);
if (input.dataset.msdLastLogged !== digits) {
input.dataset.msdLastLogged = digits;
msdSubmitFunnelEvent({
x_session_id: msdFunnelSessionId(),
x_event_type: 'phone_entered',
x_phone: digits,
x_cart_json: msdCurrentCartSnapshot()
});
}
}
});
}
var h5 = dlg.querySelector('#otpSection h5');
if (h5 && h5.textContent.indexOf('Enter the code') === -1) {
h5.textContent = 'Enter the code';
}
}
new MutationObserver(function() {
var dlg = document.querySelector('.login_dialog');
if (dlg) { reskinLoginDialog(dlg); }
}).observe(document.body, { childList: true, subtree: true });
document.querySelectorAll('.o_wsale_my_cart a[href="/shop/cart"]').forEach(function(a) {
a.setAttribute('href', 'javascript:void(0)');
a.addEventListener('click', function(e) { e.preventDefault(); openDrawer(); });
});
function pulseLogo() {
document.querySelectorAll('.navbar-brand.logo').forEach(function(l) {
l.classList.add('msd-logo-pulse');
setTimeout(function() { l.classList.remove('msd-logo-pulse'); }, 700);
});
}
function showToast(n) {
if (window.innerWidth > 991) return;
document.getElementById('msd-toast-msg').textContent = n + ' item' + (n > 1 ? 's' : '') + ' added';
var t = document.getElementById('msd-toast');
t.classList.add('show');
clearTimeout(t._msdT);
t._msdT = setTimeout(function() { t.classList.remove('show'); }, 3500);
}
document.getElementById('msd-toast-view').addEventListener('click', function(e) {
e.preventDefault();
document.getElementById('msd-toast').classList.remove('show');
openDrawer();
});
document.querySelectorAll('.my_cart_quantity').forEach(function(b) {
if (lastQty === null) lastQty = parseInt(b.textContent, 10) || 0;
var mo = new MutationObserver(function() {
var n = parseInt(b.textContent, 10) || 0;
if (n > lastQty) {
b.classList.add('msd-bump');
setTimeout(function() { b.classList.remove('msd-bump'); }, 400);
pulseLogo();
showToast(n - lastQty);
if (drawerOpen) refreshDrawer();
}
lastQty = n;
});
mo.observe(b, { characterData: true, childList: true, subtree: true });
});
if (document.querySelector('.js_add_cart_variants')) {
document.body.classList.add('msd-pdp');
function updatePerUnit() {
document.querySelectorAll('.js_attribute_value').forEach(function(li) {
var input = li.querySelector('input.js_variant_change');
var label = li.querySelector('.radio_input_value');
if (!input || !label) return;
var valName = input.getAttribute('data-value_name') || '';
var m = valName.match(/(\d+(?:\.\d+)?)\s*(kg|gm|g|ml|l)\b/i);
if (!m) return;
var existing = label.querySelector('.msd-per-unit');
if (existing) return;
var qty = parseFloat(m[1]), unit = m[2].toLowerCase();
var grams = unit === 'kg' ? qty * 1000 : (unit === 'l' ? qty * 1000 : qty);
var priceEl = document.querySelector('.product_price .oe_currency_value, #product_details .oe_currency_value');
var price = priceEl ? parseFloat(priceEl.textContent.replace(/,/g, '')) : null;
if (!price || !grams) return;
var per100 = (price / grams) * 100;
var tag = document.createElement('span');
tag.className = 'msd-per-unit';
tag.textContent = '₹' + per100.toFixed(2) + ' / 100' + (unit === 'ml' || unit === 'l' ? 'ml' : 'g');
label.appendChild(tag);
});
}
updatePerUnit();
document.querySelectorAll('input.js_variant_change').forEach(function(inp) {
inp.addEventListener('change', function() { setTimeout(updatePerUnit, 350); });
});
var addBtn = document.getElementById('add_to_cart');
if (addBtn) {
addBtn.addEventListener('click', function() {
addBtn.classList.add('msd-spinning');
setTimeout(function() { addBtn.classList.remove('msd-spinning'); }, 900);
}, true);
}
var wrap = document.getElementById('add_to_cart_wrap');
if (wrap && addBtn) {
var bar = document.createElement('div');
bar.id = 'msd-sticky-pdp';
var img = document.querySelector('.product_detail_img');
var name = document.querySelector('h1[itemprop="name"], .product_name, h1');
var price = document.querySelector('.product_price .oe_currency_value');
bar.innerHTML = '<img src="' + (img ? img.src : '') + '" alt=""/>' +
'<div class="msd-sp-info"><div class="msd-sp-name">' + (name ? name.textContent.trim() : '') + '</div>' +
'<div class="msd-sp-price">' + (price ? '₹' + price.textContent : '') + '</div></div>' +
'<button type="button">Add to Cart</button>';
bar.querySelector('button').addEventListener('click', function() { addBtn.click(); });
document.body.appendChild(bar);
var io = new IntersectionObserver(function(entries) {
entries.forEach(function(en) {
bar.classList.toggle('show', !en.isIntersecting && window.scrollY > 200);
});
}, { threshold: 0 });
io.observe(wrap);
}
}
})();
</script>
<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-5GXDWJV6"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->
<!-- == razorpay-sdk start == -->
<script>(function(){var U='https://checkout.razorpay.com/v1/checkout.js';if(location.pathname.replace(/\/+$/,'')!=='/hyderabad'){document.write('<script src="'+U+'"><\/script>');return;}var d=false;function L(){if(d)return;d=true;var s=document.createElement('script');s.src=U;s.async=true;document.head.appendChild(s);}['pointerdown','keydown','touchstart'].forEach(function(e){window.addEventListener(e,L,{once:true,passive:true});});window.addEventListener('load',function(){setTimeout(L,2500);});})();</script><script src="https://checkout.razorpay.com/v1/checkout.js"></script><div class="razorpay-container" style="z-index: 2147483647; position: fixed; top: 0px; display: none; left: 0px; height: 100%; width: 100%; max-height: 100dvh; backface-visibility: hidden; overflow-y: visible;"><style>@keyframes rzp-rot{to{transform: rotate(360deg);}}@-webkit-keyframes rzp-rot{to{-webkit-transform: rotate(360deg);}} .razorpay-container > iframe {min-height: 100%!important;} .razorpay-checkout-frame.razorpay-mobile-layout{max-width:450px !important;height:90vh !important;min-height:0 !important;max-height:900px !important;position:fixed !important;top:50% !important;left:50% !important;transform:translate(-50%,-50%) !important;border-radius:16px !important;overflow:hidden !important;}@media(max-width:450px){.razorpay-checkout-frame.razorpay-mobile-layout{max-width:100% !important;width:100% !important;height:100% !important;max-height:100% !important;top:0 !important;left:0 !important;transform:none !important;border-radius:0 !important;}}</style><div class="razorpay-backdrop" style="min-height: 100%; transition: 0.3s ease-out; position: fixed; top: 0px; left: 0px; width: 100%; height: 100%;"><span style="text-decoration: none; background: rgb(214, 68, 68); border: 1px dashed white; padding: 3px; opacity: 0; transform: rotate(45deg); transition: opacity 0.3s ease-in; font-family: lato, ubuntu, helvetica, sans-serif; color: white; position: absolute; width: 200px; text-align: center; right: -50px; top: 50px;">Test Mode</span></div><iframe allowtransparency="true" frameborder="0" width="100%" height="100%" src="https://api.razorpay.com/v1/checkout/public?traffic_env=production&build=1b4250556cdb9f8ef0d000141525fbe26d30a0b3&build_v1=755bf4b2e2419bf0ffc2bac7c1fd6e126753d61d&checkout_v2=1&new_session=1&rzp_device_id=1.82a5967c01810686a4d382a1f2cc3c43ff507a62.1790901623802.41336205&unified_session_id=TuQzvjz3fyAhwW" class="razorpay-checkout-frame" allow="otp-credentials; payment; clipboard-write; publickey-credentials-get https://api.razorpay.com; publickey-credentials-create 'self' https://api.razorpay.com; web-share; camera *; local-network-access 'none'; loopback-network 'none';" style="opacity: 1; height: 100%; position: relative; background: none; display: block; border: 0px transparent; margin: 0px; padding: 0px; z-index: 2;"></iframe></div>
<!-- == razorpay-sdk end == -->
<!-- == quick signup fallback start == -->
<!-- Every form and button here carries o_no_wait_lazy_js: Odoo's lazyloader otherwise blocks all
submits and button clicks until the lazy bundle loads - exactly the window this popup is for. -->
<!-- If the OTP login (tus_website_extend, bound only once web.assets_frontend_lazy has loaded)
has not opened within 500 ms of a tap on .signup_open, show a light name + mobile form
instead. It posts to /website/form/res.partner (res.partner is form-enabled, name + phone
whitelisted), which creates the customer. Self-contained: no Odoo JS needed. -->
<style>
#msq-ov{position:fixed;inset:0;z-index:2400;background:rgba(20,16,12,.55);display:none;align-items:flex-end;justify-content:center}
#msq-ov.show{display:flex}
@media (min-width:576px){#msq-ov{align-items:center}}
#msq-box{background:#fff;color:#1f1a14;width:100%;max-width:420px;border-radius:18px 18px 0 0;padding:24px 20px calc(20px + env(safe-area-inset-bottom));box-shadow:0 -8px 30px rgba(0,0,0,.18);font-family:inherit;position:relative}
@media (min-width:576px){#msq-box{border-radius:18px;padding:28px}}
@media (prefers-reduced-motion:no-preference){#msq-ov.show #msq-box{animation:msqUp .2s ease-out}}
@keyframes msqUp{from{transform:translateY(24px);opacity:0}to{transform:none;opacity:1}}
#msq-box h2{margin:0 36px 6px 0;font-size:22px;line-height:1.25;color:#004609}
#msq-box p.msq-sub{margin:0 0 18px;font-size:15px;line-height:1.5;color:#4a4238}
#msq-box label{display:block;font-size:14px;font-weight:600;margin:0 0 6px}
#msq-box input{display:block;width:100%;box-sizing:border-box;min-height:48px;padding:10px 14px;font-size:16px;border:1.5px solid #cfc6b8;border-radius:10px;background:#fff;color:#1f1a14;margin:0 0 4px}
#msq-box input:focus{outline:none;border-color:#006434;box-shadow:0 0 0 3px rgba(0,100,52,.18)}
#msq-box input[aria-invalid=true]{border-color:#b3261e}
#msq-box .msq-field{margin-bottom:14px}
#msq-box .msq-phone{display:flex;align-items:stretch;gap:8px}
#msq-box .msq-cc{display:flex;align-items:center;padding:0 12px;border:1.5px solid #cfc6b8;border-radius:10px;font-size:16px;color:#4a4238;background:#f6f3ee}
#msq-box .msq-err{min-height:18px;font-size:13px;color:#b3261e}
#msq-box button.msq-go{width:100%;min-height:50px;border:none;border-radius:999px;background:#006434;color:#fff;font-size:16px;font-weight:700;cursor:pointer;margin-top:4px;transition:background .15s}
#msq-box button.msq-go:hover{background:#004609}
#msq-box button.msq-go[disabled]{opacity:.7;cursor:wait}
#msq-box button.msq-go:focus-visible,#msq-x:focus-visible,#msq-box .msq-link:focus-visible{outline:2px solid #006434;outline-offset:2px}
#msq-x{position:absolute;top:12px;right:12px;width:44px;height:44px;border:none;background:transparent;font-size:26px;line-height:1;color:#4a4238;cursor:pointer;border-radius:50%}
#msq-x:hover{background:#f1ede6}
#msq-box .msq-link{display:block;width:100%;margin-top:10px;min-height:44px;border:1.5px solid #006434;border-radius:999px;background:#fff;color:#006434;font-size:15px;font-weight:600;cursor:pointer}
#msq-box .msq-done{display:none;text-align:center}
#msq-box.ok .msq-form{display:none}
#msq-box.ok .msq-done{display:block}
#msq-box .msq-tick{width:52px;height:52px;margin:4px auto 12px;border-radius:50%;background:#e6f2ea;color:#006434;display:grid;place-items:center}
#msq-box .msq-guest{display:block;width:100%;min-height:44px;margin-top:6px;border:none;background:transparent;color:#004609;font-size:15px;font-weight:600;text-decoration:underline;text-underline-offset:3px;cursor:pointer;border-radius:8px}
#msq-box .msq-guest:hover{background:#f1ede6}
#msq-box .msq-guest:focus-visible{outline:2px solid #006434;outline-offset:2px}
#msq-box .msq-status{min-height:18px;font-size:13px;color:#4a4238;text-align:center;margin-top:8px}
#msq-box .msq-notyou{display:block;margin:-8px 0 14px;padding:0;min-height:32px;border:none;background:none;color:#004609;font-size:14px;text-decoration:underline;text-underline-offset:3px;cursor:pointer;text-align:left}
#msq-box .msq-notyou[hidden]{display:none}
.msq-otp-guest{display:block;width:100%;min-height:46px;margin-top:14px;border:1.5px solid #006434;border-radius:999px;background:#fff;color:#006434;font-size:15px;font-weight:600;cursor:pointer}
.msq-otp-guest:hover{background:#e6f2ea}
.msq-otp-guest:focus-visible{outline:2px solid #006434;outline-offset:2px}
</style>
<div id="msq-ov" role="presentation">
<div id="msq-box" role="dialog" aria-modal="true" aria-labelledby="msq-title">
<button type="button" id="msq-x" class="o_no_wait_lazy_js" aria-label="Close">×</button>
<div class="msq-form">
<h2 id="msq-title">Welcome to Miller Stores</h2>
<p class="msq-sub">Enter your name and mobile number to log in.</p>
<button type="button" class="msq-notyou o_no_wait_lazy_js" id="msq-notyou" hidden="hidden">Not <span id="msq-notyou-name"></span>? Use a different name</button>
<form id="msq-f" class="o_no_wait_lazy_js" novalidate="novalidate">
<div class="msq-field">
<label for="msq-name">Your name</label>
<input id="msq-name" name="name" type="text" autocomplete="name" maxlength="80" required="required">
<div class="msq-err" id="msq-name-err" aria-live="polite"></div>
</div>
<div class="msq-field">
<label for="msq-phone">Mobile number</label>
<div class="msq-phone">
<span class="msq-cc" aria-hidden="true">+91</span>
<input id="msq-phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel-national" maxlength="14" required="required">
</div>
<div class="msq-err" id="msq-phone-err" aria-live="polite"></div>
</div>
<button type="submit" class="msq-go o_no_wait_lazy_js" id="msq-send">Send OTP</button>
<div class="msq-status" id="msq-status" aria-live="polite"></div>
<button type="button" class="msq-guest o_no_wait_lazy_js" id="msq-guest">Login as guest</button>
<div class="msq-err" id="msq-form-err" aria-live="polite" style="text-align:center;margin-top:4px"></div>
</form>
</div>
<div class="msq-done" aria-live="polite">
<div class="msq-tick"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg></div>
<h2 id="msq-done-title" style="margin:0 0 6px">Thank you!</h2>
<p class="msq-sub" id="msq-done-msg"></p>
<button type="button" class="msq-go o_no_wait_lazy_js" id="msq-close2">Continue shopping</button>
</div>
</div>
</div>
<script>
(function () {
if (window.__msq) return; window.__msq = true;
var WAIT = 500, ov = document.getElementById('msq-ov'), box = document.getElementById('msq-box');
var f = document.getElementById('msq-f'), nameI = document.getElementById('msq-name'), phoneI = document.getElementById('msq-phone');
var sendB = document.getElementById('msq-send'), guestB = document.getElementById('msq-guest');
var timer = null, lastFocus = null, bypass = false, watch = null, busy = false, lastLogged = '';
/* Fetch the lazy bundle (where the OTP login lives) at high priority now, instead of after
window.load behind the page's images. Odoo still executes it at the usual moment, but from
cache, so the OTP login is ready seconds sooner. Same URL as the real tag: no double download. */
try {
var lz = document.querySelector('script[data-src*="assets_frontend_lazy"]');
if (lz && !document.querySelector('link[rel="preload"][href="' + lz.getAttribute('data-src') + '"]')) {
var pl = document.createElement('link'); pl.rel = 'preload'; pl.as = 'script';
pl.href = lz.getAttribute('data-src'); pl.setAttribute('fetchpriority', 'high');
document.head.appendChild(pl);
}
} catch (e) {}
/* the OTP dialog is position:fixed, so offsetParent is always null for it: use client rects */
function visibleDialog() {
var ds = document.querySelectorAll('.login_dialog');
for (var i = 0; i < ds.length; i++) { if (ds[i].getClientRects().length) return ds[i]; }
return null;
}
function realDialogOpen() { return !!visibleDialog(); }
function dismissSurvey() { var sv = document.getElementById('msd-exit-survey'); if (sv) sv.classList.remove('show'); }
function closeRealDialog() {
var dlg = visibleDialog(); var x = dlg && dlg.querySelector('.close_modal');
if (x) x.click();
document.body.classList.remove('login_dialog_open');
/* view 8606 opens its exit survey on every .close_modal click; this close is ours, not the visitor's */
dismissSurvey();
}
function setErr(id, msg) { document.getElementById(id).textContent = msg; }
function digits(v) { var d = (v || '').replace(/\D/g, ''); if (d.length === 12 && d.indexOf('91') === 0) d = d.slice(2); if (d.length === 11 && d[0] === '0') d = d.slice(1); return d; }
function validPhone(p) { return /^[6-9]\d{9}$/.test(p); }
/* Guest identity: after "Login as guest" the header's Login label shows the guest's first name, on every page and
later visits (this browser only). It stays the login trigger, so tapping it can still verify with OTP. Once really
logged in, Odoo renders its own account menu and there is no .signup_open for this to touch. */
var GKEY = 'msq_guest', GDAYS = 180;
function guestGet() {
try { var g = JSON.parse(localStorage.getItem(GKEY) || 'null');
if (g && g.name && g.phone && Date.now() - (g.t || 0) < GDAYS * 864e5) return g; } catch (e) {}
return null;
}
function guestSet(name, phone) { try { localStorage.setItem(GKEY, JSON.stringify({ name: name, phone: phone, t: Date.now() })); } catch (e) {} }
function guestClear() { try { localStorage.removeItem(GKEY); } catch (e) {} }
function paintGuest() {
var g = guestGet();
document.querySelectorAll('header .signup_open').forEach(function (a) {
/* the label span, not an icon: Odoo's lazyloader prepends a <span class="fa fa-refresh fa-spin"> to a link
tapped while it is still loading, and that would otherwise be the first span */
var sp = a.querySelector('span[data-msq-label]') ||
[].slice.call(a.querySelectorAll('span')).filter(function (x) { return !/\bfa\b/.test(x.className); })[0];
if (!sp) return;
if (!sp.hasAttribute('data-msq-label')) sp.setAttribute('data-msq-label', sp.textContent); /* remember "Login" */
a.dataset.msqLabel = sp.getAttribute('data-msq-label');
if (g) {
var first = g.name.trim().split(/\s+/)[0];
sp.textContent = first.length > 14 ? first.slice(0, 13) + '…' : first;
a.setAttribute('title', g.name + ' (guest) - tap to log in with OTP');
a.setAttribute('aria-label', 'Shopping as ' + g.name + '. Log in with OTP');
} else {
sp.textContent = a.dataset.msqLabel;
a.removeAttribute('title'); a.removeAttribute('aria-label');
}
});
}
paintGuest();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', paintGuest);
/* Guest checkout (website.account_on_checkout = optional): prefill the address form with the guest's name and
phone - only into empty fields, still editable. Covers every route in: side cart, Buy now, /shop/cart. */
function prefillGuestCheckout() {
var g = guestGet(), path = location.pathname;
if (!g || (path.indexOf('/shop/checkout') !== 0 && path.indexOf('/shop/address') !== 0)) return;
[['name', g.name], ['phone', g.phone]].forEach(function (f) {
var el = document.querySelector('form input[name="' + f[0] + '"]');
if (el && !el.value) { el.value = f[1]; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', prefillGuestCheckout); else prefillGuestCheckout();
function csrf() { var t = document.querySelector('input[name="csrf_token"]'); return (window.odoo && odoo.csrf_token) || (t && t.value) || ''; }
/* /onlinecrm: same model, session id and event as the cart drawer's own phone capture (view 8606),
so a number typed here lands on the same visitor row - whether or not OTP is ever completed */
function funnelSid() {
var sid = ''; try { sid = sessionStorage.getItem('msd_funnel_id'); } catch (e) {}
if (!sid) { sid = 'msd-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); try { sessionStorage.setItem('msd_funnel_id', sid); } catch (e) {} }
return sid;
}
function logPhone(ph) {
if (!validPhone(ph) || ph === lastLogged) return;
lastLogged = ph;
try { sessionStorage.setItem('msd_pending_phone', ph); } catch (e) {}
var fd = new FormData();
fd.append('x_session_id', funnelSid()); fd.append('x_event_type', 'phone_entered'); fd.append('x_phone', ph);
fd.append('csrf_token', csrf());
try { fetch('/website/form/x_msd_checkout_log', { method: 'POST', body: fd, credentials: 'same-origin' }).catch(function () {}); } catch (e) {}
}
function open() {
if (ov.classList.contains('show')) return;
lastFocus = document.activeElement;
if (lazyWaiting()) shownWhileWaiting = true;
box.classList.remove('ok'); setErr('msq-form-err', ''); setErr('msq-status', '');
var g = guestGet(), ny = document.getElementById('msq-notyou');
if (g) { if (!nameI.value) nameI.value = g.name; if (!phoneI.value) phoneI.value = g.phone;
document.getElementById('msq-notyou-name').textContent = g.name.trim().split(/\s+/)[0]; ny.hidden = false; }
else ny.hidden = true;
ov.classList.add('show');
setTimeout(function () { nameI.focus(); }, 30);
/* the OTP dialog may still arrive late for this same tap: this popup replaces it */
watch = setInterval(function () { if (!busy && realDialogOpen()) closeRealDialog(); }, 250);
}
function close() {
ov.classList.remove('show');
clearInterval(watch); watch = null; busy = false;
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
/* Odoo 16's lazyloader holds a click made while o_lazy_js_waiting is on <body> and re-dispatches
it (untrusted) once the lazy bundle arrives. If this popup already answered that tap, the replay
must not pop the OTP dialog open by itself. This script runs from the footer BEFORE the class is
added (DOMContentLoaded), so only a seen-then-cleared class marks the end of the wait. */
var shownWhileWaiting = false, waitEndedAt = 0, sawWaiting = false, polls = 0;
function lazyWaiting() { var w = !!document.body && document.body.classList.contains('o_lazy_js_waiting'); if (w) sawWaiting = true; return w; }
(function watchWait() {
if (lazyWaiting() || !sawWaiting) { if (++polls < 600) setTimeout(watchWait, 100); return; }
waitEndedAt = Date.now();
})();
/* capture phase on document: runs before the lazyloader's element-level handler and the widget */
document.addEventListener('click', function (ev) {
var t = ev.target.closest && ev.target.closest('.signup_open');
if (!t || bypass) return;
if (!waitEndedAt && sawWaiting && !lazyWaiting()) waitEndedAt = Date.now();
if (!ev.isTrusted && shownWhileWaiting && !lazyWaiting() && waitEndedAt && Date.now() - waitEndedAt < 4000) {
ev.preventDefault(); ev.stopImmediatePropagation(); shownWhileWaiting = false; return; /* the replay */
}
ev.preventDefault(); /* href="#" must not jump the page */
clearTimeout(timer);
timer = setTimeout(function () { if (!realDialogOpen()) open(); }, WAIT);
}, true);
document.getElementById('msq-notyou').addEventListener('click', function () {
guestClear(); paintGuest(); nameI.value = ''; phoneI.value = ''; lastLogged = '';
this.hidden = true; nameI.focus();
});
/* "Login as guest" inside the site's own OTP dialog (tus_website_extend LoginDialog): injected each time the dialog
renders. Tapping it closes the OTP dialog quietly (no exit survey) and opens this popup, carrying any number
already typed, so the shopper can continue as a guest. */
function addGuestToOtpDialog() {
var dlg = visibleDialog(); if (!dlg || dlg.querySelector('.msq-otp-guest')) return;
var sec = dlg.querySelector('#mobileInputSection'); if (!sec) return;
var b = document.createElement('button');
b.type = 'button'; b.className = 'msq-otp-guest o_no_wait_lazy_js'; b.textContent = 'Login as guest';
b.addEventListener('click', function (ev) {
ev.preventDefault(); ev.stopPropagation();
var typed = digits((dlg.querySelector('.mobile_number') || {}).value || '');
closeRealDialog();
open();
if (typed && !phoneI.value) { phoneI.value = typed; logPhone(typed); }
setTimeout(function () { (nameI.value ? phoneI : nameI).focus(); }, 60);
});
sec.appendChild(b);
}
new MutationObserver(function () { if (document.querySelector('.login_dialog')) addGuestToOtpDialog(); })
.observe(document.documentElement, { childList: true, subtree: true });
document.getElementById('msq-x').addEventListener('click', close);
document.getElementById('msq-close2').addEventListener('click', close);
ov.addEventListener('click', function (ev) { if (ev.target === ov) close(); });
document.addEventListener('keydown', function (ev) {
if (!ov.classList.contains('show')) return;
if (ev.key === 'Escape') { close(); return; }
if (ev.key === 'Tab') { /* keep focus inside the dialog */
var fs = [].slice.call(box.querySelectorAll('button,input')).filter(function (e) { return e.getClientRects().length && !e.disabled; });
if (!fs.length) return;
var a = fs[0], z = fs[fs.length - 1];
if (ev.shiftKey && document.activeElement === a) { ev.preventDefault(); z.focus(); }
else if (!ev.shiftKey && document.activeElement === z) { ev.preventDefault(); a.focus(); }
}
});
phoneI.addEventListener('input', function () {
phoneI.removeAttribute('aria-invalid'); setErr('msq-phone-err', '');
logPhone(digits(phoneI.value)); /* logged the moment a full number is typed */
});
nameI.addEventListener('input', function () { nameI.removeAttribute('aria-invalid'); setErr('msq-name-err', ''); });
function check() {
var name = nameI.value.trim().replace(/\s+/g, ' '), ph = digits(phoneI.value), ok = true;
if (name.length < 2) { nameI.setAttribute('aria-invalid', 'true'); setErr('msq-name-err', 'Please enter your name.'); ok = false; }
if (!validPhone(ph)) { phoneI.setAttribute('aria-invalid', 'true'); setErr('msq-phone-err', 'Please enter a valid 10-digit mobile number.'); ok = false; }
if (!ok) { (name.length < 2 ? nameI : phoneI).focus(); return null; }
logPhone(ph);
try { sessionStorage.setItem('msq_name', name); } catch (e) {}
return { name: name, phone: ph };
}
function lock(on) { sendB.disabled = on; guestB.disabled = on; }
/* Send OTP: hand the number to the site's own OTP login (tus_website_extend). Typing the 10
digits into its field is what makes it send the SMS. That widget only binds once the lazy
bundle has executed (o_lazy_js_waiting comes off <body>), which on a slow page is several
seconds after load - so wait for it here rather than click before it is ready. Clicking the
trigger while the lazyloader is still waiting only gets the click queued and replayed later,
stacking dialogs, so we never click until ready. */
function widgetReady() { return !!document.body && !document.body.classList.contains('o_lazy_js_waiting'); }
f.addEventListener('submit', function (ev) {
ev.preventDefault();
var v = check(); if (!v) return;
if (!document.querySelector('.signup_open')) { close(); return; } /* already logged in */
busy = true; lock(true); sendB.textContent = 'Sending OTP…'; setErr('msq-form-err', '');
var slowNote = setTimeout(function () { setErr('msq-status', 'Getting secure login ready, one moment…'); }, 1500);
var deadline = Date.now() + 45000;
function done(ok) {
clearTimeout(slowNote); busy = false; lock(false); sendB.textContent = 'Send OTP'; setErr('msq-status', '');
if (!ok) setErr('msq-form-err', 'Could not open OTP login. Please try again, or log in as guest.');
}
function fillNumber(left) {
var dlg = visibleDialog(); var m = dlg && dlg.querySelector('.mobile_number');
if (m) {
m.focus(); m.value = v.phone;
m.dispatchEvent(new Event('input', { bubbles: true }));
m.dispatchEvent(new Event('change', { bubbles: true }));
clearInterval(watch); watch = null; ov.classList.remove('show'); done(true);
} else if (left > 0) { setTimeout(function () { fillNumber(left - 1); }, 100); }
else { clearInterval(watch); watch = null; ov.classList.remove('show'); done(true); } /* dialog open, they can type */
}
(function waitReady() {
if (!ov.classList.contains('show')) { done(false); return; } /* visitor closed it */
if (realDialogOpen()) { fillNumber(25); return; } /* e.g. opened by the tap replay */
if (widgetReady()) {
var trig = document.querySelector('.signup_open');
if (!trig) { done(false); return; }
bypass = true; trig.click(); bypass = false; /* single click, lazyloader is done */
var n = 0;
(function poll() {
if (realDialogOpen()) return fillNumber(25);
if (++n < 25) setTimeout(poll, 100);
else if (Date.now() < deadline) { setTimeout(waitReady, 300); }
else done(false);
})();
return;
}
if (Date.now() > deadline) { done(false); return; }
setTimeout(waitReady, 200);
})();
});
/* Login as guest: save name + number as a customer (res.partner via the website form) */
guestB.addEventListener('click', function () {
var v = check(); if (!v) return;
lock(true); guestB.textContent = 'Saving…'; setErr('msq-form-err', '');
var fd = new FormData();
fd.append('name', v.name); fd.append('phone', v.phone); fd.append('csrf_token', csrf());
fetch('/website/form/res.partner', { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res || !res.id) throw new Error('form');
guestSet(v.name, v.phone); paintGuest();
document.getElementById('msq-done-msg').textContent =
'Thanks, ' + v.name.split(' ')[0] + '. You are shopping as a guest, and we have saved +91 ' + v.phone + '.';
box.classList.add('ok');
setTimeout(function () { document.getElementById('msq-close2').focus(); }, 30);
})
.catch(function () { setErr('msq-form-err', 'Could not save right now. Please try again.'); })
.then(function () { lock(false); guestB.textContent = 'Login as guest'; });
});
})();
</script>
<!-- == quick signup fallback end == -->
<!-- == fast add to cart start == -->
<script>
/* Product page "Add to cart": update the cart count as soon as the server replies. tus_website_extend's
_addToCartInPage awaited the fly-to-cart animation (~1.7 s) BEFORE updating the count, so adding felt slow.
Same logic, the animation now runs alongside instead of in front. Registered as an Odoo module that depends on
the theme's module, so it always overrides after it. */
(function () {
/* odoo.define only exists once the lazy bundle (web.assets_frontend_lazy) has loaded - later than this footer
script runs - so wait for it. .include() patches the prototype, so widgets already started pick it up too. */
var tries = 0;
(function wait() {
if (!(window.odoo && odoo.define)) { if (++tries < 600) { setTimeout(wait, 100); } return; }
define();
})();
function define() {
odoo.define('millerstores.fast_add_to_cart', function (require) {
'use strict';
var ws = require('website_sale.website_sale');
var utils = require('website_sale.utils');
require('@tus_website_extend/js/website_sale');
ws.WebsiteSale.include({
_addToCartInPage: function (params) {
var self = this;
params.force_create = true;
return this._rpc({ route: '/shop/cart/update_json', params: params }).then(function (data) {
sessionStorage.setItem('website_sale_cart_quantity', data.cart_quantity);
if (data.cart_quantity && data.cart_quantity !== parseInt($('.my_cart_quantity').text(), 10)) {
utils.updateCartNavBar(data);
if ($('div[data-image_width]').data('image_width') !== 'none') {
var myCart = window.innerWidth > 992 ? $('header .o_wsale_my_cart').first() : $('header .o_wsale_my_cart').last();
utils.animateClone(myCart, self.$itemImgContainer, 25, 40);
}
}
return data;
});
},
});
return { patched: true };
});
}
})();
</script>
<!-- == fast add to cart end == -->
<!-- == cod-home-delivery-only start == -->
<script>
(function () {
if (location.pathname.replace(/\/+$/, '') !== '/shop/payment') { return; }
var COD = '18', PICKUP = '9';
function card() {
var r = document.querySelector('input[name="o_payment_radio"][data-payment-option-id="' + COD + '"]');
if (!r) { return null; }
var el = r.closest('.o_payment_option_card') || r.parentElement;
while (el.parentElement && el.parentElement.querySelectorAll('.o_payment_option_card').length === 1 &&
!el.parentElement.matches('form, .o_payment_form, #o_payment_form_options')) { el = el.parentElement; }
return { radio: r, box: el };
}
function apply() {
var c = card(); if (!c) { return; }
var sel = document.querySelector('input[name="delivery_type"]:checked');
var pickup = !!sel && sel.value === PICKUP;
c.box.style.display = pickup ? 'none' : '';
if (pickup && c.radio.checked) { c.radio.checked = false; }
}
document.addEventListener('change', function (e) {
if (e.target && e.target.name === 'delivery_type') { setTimeout(apply, 50); setTimeout(apply, 800); }
});
new MutationObserver(apply).observe(document.body, { childList: true, subtree: true });
apply();
})();
</script>
<!-- == cod-home-delivery-only end == -->