PageSourceSearch

https://storywell.in/js/books.js

js storywell.in collected 2026-09-29 07:08:15 UTC 42,752 bytes, 986 lines download raw bytes

1/* ========================================
2   StoryWell — Books Catalog + UPI Payment
3   Modern store experience
4   ======================================== */
5
6(function () {
7  // ---- Configuration ----
8  var UPI_ID = 'storywellbooks@icici';
9  var UPI_NAME = 'StoryWell Books';
10  var DESC_MAX_CHARS = 180;
11  // Google Sheets order backup — paste your Apps Script Web App URL below
12  // Setup: see docs/google-sheets-order-setup.md
13  var SHEETS_ENDPOINT = 'https://script.google.com/macros/s/AKfycbzTAzix1CdQMLmn0V6LBDvoFZkUrHkbFHpkNHYA-vnAgUv9rROooZjxrGINMuykzzqT1Q/exec';
14
15  var bookGrid = document.getElementById('bookGrid');
16  var categoryFilter = document.getElementById('categoryFilter');
17  var loadingMsg = document.getElementById('loadingMsg');
18
19  // UPI Modal elements
20  var upiModal = document.getElementById('upiModal');
21  var closeModal = document.getElementById('closeModal');
22  var modalBookTitle = document.getElementById('modalBookTitle');
23  var modalAmount = document.getElementById('modalAmount');
24  var modalUpiId = document.getElementById('modalUpiId');
25  var modalQr = document.getElementById('modalQr');
26  var upiDeepLink = document.getElementById('upiDeepLink');
27
28  // Product detail modal elements
29  var detailModal = document.getElementById('productDetailModal');
30
31  var allBooks = [];
32  var promoCodes = [];
33
34  // Current order state (details-first checkout)
35  var currentFinalPrice = 0;      // price after promo discount
36  var currentPromo = null;        // matched promo object, if applied
37  var currentOrderItems = [];     // [{ title, qty, price }]
38  var currentOrderFromCart = false;
39
40  // ---- Determine base path for locale subdirectories ----
41  var scriptEl = document.querySelector('script[src*="books.js"]');
42  var booksPrefix = (scriptEl && scriptEl.getAttribute('src').indexOf('../') === 0) ? '../' : '';
43  var booksJsonPath = booksPrefix + 'content/books/books.json';
44  var promosJsonPath = booksPrefix + 'content/promos/promos.json';
45
46  // ---- Section definitions ----
47  var sections = [
48    { key: 'children', title: "Children's Books", categories: ['children', 'values', 'puzzles'] },
49    { key: 'academic', title: "Children's Non-Fiction", categories: ['academic'] },
50    { key: 'fiction', title: 'Fiction', categories: ['fiction', 'novels'] },
51    { key: 'nonfiction', title: 'Non-Fiction', categories: ['nonfiction'] },
52    { key: 'stationery', title: 'Stationery & More', categories: ['stationery', 'notebooks', 'bookmarks', 'space'] }
53  ];
54
55  // ---- Language display names ----
56  var languageNames = {
57    'english': 'English',
58    'hindi': 'Hindi',
59    'bilingual-hi-en': 'Bilingual (Hindi & English)',
60    'bengali': 'Bengali',
61    'gujarati': 'Gujarati',
62    'marathi': 'Marathi',
63    'odia': 'Odia',
64    'tamil': 'Tamil',
65    'telugu': 'Telugu'
66  };
67
68  // ---- Helpers ----
69  function escapeHtml(str) {
70    var div = document.createElement('div');
71    div.textContent = str;
72    return div.innerHTML;
73  }
74
75  function truncateText(text, max) {
76    if (!text || text.length <= max) return text || '';
77    return text.substring(0, max).replace(/\s+\S*$/, '') + '...';
78  }
79
80  function formatCategory(cat) {
81    var map = {
82      novels: 'Novel', children: "Children's Book", values: "Values",
83      notebooks: 'Notebook', bookmarks: 'Bookmark', puzzles: 'Puzzle & Game',
84      fiction: 'Fiction', nonfiction: 'Non-Fiction', academic: 'Academic',
85      stationery: 'Stationery', space: 'Space'
86    };
87    return map[cat] || cat;
88  }
89
90  function renderStars(rating) {
91    var html = '';
92    for (var i = 1; i <= 5; i++) {
93      html += i <= rating ? '&#9733;' : '&#9734;';
94    }
95    return html;
96  }
97
98  function getAverageRating(bookId) {
99    var reviews = getReviews(bookId);
100    if (reviews.length === 0) return { avg: 0, count: 0 };
101    var total = 0;
102    for (var i = 0; i < reviews.length; i++) total += reviews[i].rating;
103    return { avg: Math.round(total / reviews.length), count: reviews.length };
104  }
105
106  function buildPriceHtml(book) {
107    if (book.recommended_price && book.recommended_price > book.price) {
108      var discount = Math.round((1 - book.price / book.recommended_price) * 100);
109      return '<span class="book-price">&#8377;' + book.price +
110        ' <span class="book-mrp">&#8377;' + book.recommended_price + '</span>' +
111        ' <span class="book-discount">' + discount + '% off</span></span>';
112    }
113    return '<span class="book-price">&#8377;' + book.price + '</span>';
114  }
115
116  function buildSpecsHtml(book) {
117    var specs = [];
118    if (book.format) specs.push(book.format.charAt(0).toUpperCase() + book.format.slice(1));
119    if (book.pages) specs.push(book.pages + ' pages');
120    if (book.dimensions) specs.push(book.dimensions);
121    return specs.length > 0 ? specs.join(' &middot; ') : '';
122  }
123
124  function buildVariantsHtml(book) {
125    if (!book.variants || book.variants.length === 0) return '';
126    var html = '<div class="detail-variants"><label>Choose an option:</label><div class="variant-options">';
127    book.variants.forEach(function(v, i) {
128      var checked = i === 0 ? ' checked' : '';
129      html += '<label class="variant-option">' +
130        '<input type="radio" name="variant" value="' + v.price + '" data-label="' + escapeHtml(v.label) + '"' + checked + ' onchange="updateVariantPrice(this)">' +
131        '<span>' + escapeHtml(v.label) + ' &mdash; &#8377;' + v.price + '</span>' +
132      '</label>';
133    });
134    html += '</div></div>';
135    return html;
136  }
137
138  window.updateVariantPrice = function(radio) {
139    var price = parseInt(radio.value);
140    var label = radio.getAttribute('data-label');
141    var btn = document.getElementById('detailBuyBtn');
142    if (btn) {
143      var titleEl = btn.closest('.detail-info').querySelector('h2');
144      var title = titleEl ? titleEl.textContent : '';
145      btn.innerHTML = 'Buy Now &mdash; &#8377;' + price;
146      btn.setAttribute('onclick', "openUpiModal('" + encodeURIComponent(title + ' (' + label + ')') + "'," + price + ")");
147    }
148  };
149
150  window.openBulkInquiry = function(encodedTitle) {
151    var title = decodeURIComponent(encodedTitle);
152    var msg = 'Hi, I\'d like to request bulk pricing for "' + title + '". Please provide rates for:\n\n' +
153      '- 10 copies\n- 25 copies\n- 50 copies\n- 100+ copies\n\n' +
154      'Please reply to this email with bulk pricing. Thank you!';
155    window.location.href = 'mailto:[email protected]?subject=' +
156      encodeURIComponent('Bulk Order Inquiry: ' + title) +
157      '&body=' + encodeURIComponent(msg);
158  };
159
160  // ---- Reading List (localStorage) ----
161  function getReadingList() {
162    try { return JSON.parse(localStorage.getItem('sw-reading-list')) || []; }
163    catch(e) { return []; }
164  }
165
166  function saveReadingList(list) {
167    localStorage.setItem('sw-reading-list', JSON.stringify(list));
168  }
169
170  function isInReadingList(bookId) {
171    return getReadingList().indexOf(bookId) !== -1;
172  }
173
174  /* The save control is a ribbon on the cover, the same shape as the one in
175     the header, so pressing it and watching that icon gain a number reads as
176     one action. It used to be a third outlined button below Buy and Details,
177     where nobody found it. */
178  function ribbonSvg(filled) {
179    return '<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" ' +
180      'fill="' + (filled ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2">' +
181      '<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/></svg>';
182  }
183
184  function ribbonHtml(bookId) {
185    var saved = isInReadingList(bookId);
186    return '<button type="button" class="rl-ribbon' + (saved ? ' rl-active' : '') + '"' +
187      ' data-book-id="' + bookId + '" aria-pressed="' + (saved ? 'true' : 'false') + '"' +
188      ' title="' + (saved ? 'In your reading list' : 'Save to reading list') + '"' +
189      ' aria-label="' + (saved ? 'Remove from reading list' : 'Save to reading list') + '"' +
190      ' onclick="event.stopPropagation();toggleReadingList(\'' + bookId + '\')">' +
191      ribbonSvg(saved) + '</button>';
192  }
193
194  window.toggleReadingList = function(bookId) {
195    var list = getReadingList();
196    var idx = list.indexOf(bookId);
197    if (idx === -1) { list.push(bookId); } else { list.splice(idx, 1); }
198    saveReadingList(list);
199    var inList = idx === -1;
200
201    document.querySelectorAll('.rl-ribbon[data-book-id="' + bookId + '"]').forEach(function (btn) {
202      btn.classList.toggle('rl-active', inList);
203      btn.setAttribute('aria-pressed', inList ? 'true' : 'false');
204      btn.title = inList ? 'In your reading list' : 'Save to reading list';
205      btn.setAttribute('aria-label', inList ? 'Remove from reading list' : 'Save to reading list');
206      btn.innerHTML = ribbonSvg(inList);
207    });
208
209    document.querySelectorAll('.rl-btn[data-book-id="' + bookId + '"]').forEach(function (btn) {
210      btn.classList.toggle('rl-active', inList);
211      btn.innerHTML = ribbonSvg(inList) +
212        '<span>' + (inList ? 'In your reading list' : 'Save to reading list') + '</span>';
213    });
214
215    updateReadingListCount();
216    updateReadingListHint();
217  };
218
219  /* A one-line hint above the catalogue, for readers who have never saved a
220     book. It is not dismissible because it does not need to be: saving one
221     book is what takes it away, and by then it has said its piece. */
222  function updateReadingListHint() {
223    var hint = document.getElementById('rlHint');
224    if (!hint) return;
225    hint.hidden = getReadingList().length > 0;
226  }
227
228  function updateReadingListCount() {
229    var badge = document.getElementById('rlCount');
230    if (badge) {
231      var count = getReadingList().length;
232      badge.textContent = count;
233      badge.style.display = count > 0 ? 'inline-flex' : 'none';
234    }
235  }
236
237  // ---- Reviews (localStorage) ----
238  function getReviews(bookId) {
239    try { return JSON.parse(localStorage.getItem('sw-reviews-' + bookId)) || []; }
240    catch(e) { return []; }
241  }
242
243  function saveReview(bookId, review) {
244    var reviews = getReviews(bookId);
245    reviews.unshift(review);
246    localStorage.setItem('sw-reviews-' + bookId, JSON.stringify(reviews));
247  }
248
249  // ---- Load promo codes ----
250  fetch(promosJsonPath)
251    .then(function (res) { return res.json(); })
252    .then(function (data) {
253      promoCodes = (data.codes || []).filter(function(c) { return c.active; });
254    })
255    .catch(function () { promoCodes = []; });
256
257  // ---- Load books from JSON ----
258  fetch(booksJsonPath)
259    .then(function (res) { return res.json(); })
260    .then(function (data) {
261      var books = data.books || data;
262      allBooks = books.filter(function(b) { return b.enabled !== false; });
263      renderSectionedView(allBooks);
264      updateReadingListCount();
265      updateReadingListHint();
266      renderRecentlyViewed();
267      handleHashRoute();
268    })
269    .catch(function () {
270      if (loadingMsg) loadingMsg.textContent = 'Could not load books. Please try again later.';
271    });
272
273  // ---- Render sectioned view (default) ----
274  function renderSectionedView(books) {
275    if (!bookGrid) return;
276    bookGrid.innerHTML = '';
277    bookGrid.className = 'book-sections';
278
279    sections.forEach(function(section) {
280      var sectionBooks = books.filter(function(b) {
281        return section.categories.indexOf(b.category) !== -1;
282      });
283      if (sectionBooks.length === 0) return;
284
285      var sectionEl = document.createElement('div');
286      sectionEl.className = 'book-section';
287      sectionEl.id = 'section-' + section.key;
288      sectionEl.innerHTML = '<h2 class="book-section-title">' + section.title +
289        ' <span class="book-section-count">(' + sectionBooks.length + ')</span></h2>';
290
291      var grid = document.createElement('div');
292      grid.className = 'book-grid';
293      sectionBooks.forEach(function(book) { grid.appendChild(createBookCard(book)); });
294
295      sectionEl.appendChild(grid);
296      bookGrid.appendChild(sectionEl);
297    });
298  }
299
300  // ---- Render flat filtered view ----
301  function renderBooks(books) {
302    if (!bookGrid) return;
303    bookGrid.innerHTML = '';
304    bookGrid.className = 'book-grid';
305    if (books.length === 0) {
306      bookGrid.innerHTML = '<p style="text-align:center;grid-column:1/-1;color:var(--sw-muted);padding:40px;">No books found in this category.</p>';
307      return;
308    }
309    books.forEach(function (book) { bookGrid.appendChild(createBookCard(book)); });
310  }
311
312  // ---- Create a book card (compact, modern) ----
313  function createBookCard(book) {
314    var card = document.createElement('div');
315    card.className = 'book-card';
316    card.setAttribute('data-category', book.category);
317
318    var imageHtml = book.cover
319      ? window.swCoverTag(booksPrefix, book.cover, book.title, 'thumb')
320      : '<img src="' + booksPrefix + 'images/placeholder-coming-soon.svg" alt="Coming Soon" class="placeholder-cover">';
321
322    // Language badge
323    var langHtml = '';
324    if (book.language && book.language !== 'english') {
325      langHtml = '<span class="book-lang-badge">' + (languageNames[book.language] || book.language) + '</span>';
326    }
327
328    // Star ratings
329    var rating = getAverageRating(book.id);
330    var ratingHtml = '';
331    if (rating.count > 0) {
332      ratingHtml = '<div class="book-rating"><span class="book-stars">' + renderStars(rating.avg) + '</span> <span class="book-rating-count">(' + rating.count + ')</span></div>';
333    } else {
334      ratingHtml = '<div class="book-rating"><span class="book-stars book-stars-empty">' + renderStars(0) + '</span></div>';
335    }
336
337    // Truncated description
338    var shortDesc = truncateText(book.description, DESC_MAX_CHARS);
339    var hasMore = book.description && book.description.length > DESC_MAX_CHARS;
340
341    // Stock / Pre-order status
342    var stockHtml = '';
343    if (book.quantity === 0) {
344      stockHtml = '<span class="book-stock preorder">Pre-Order</span>';
345    } else if (book.quantity > 0 && book.quantity <= 5) {
346      stockHtml = '<span class="book-stock low">Only ' + book.quantity + ' left</span>';
347    }
348
349    // Buy button text (Pre-Order vs Buy)
350    var hasVariants = book.variants && book.variants.length > 0;
351    var buyLabel = book.quantity === 0 ? 'Pre-Order' : 'Buy &#8377;' + book.price;
352    // If has variants, button should open detail modal instead of quick buy
353    var buyOnclick = hasVariants
354      ? 'openProductDetail(\'' + book.id + '\')'
355      : 'openUpiModal(\'' + encodeURIComponent(book.title) + '\',' + book.price + ')';
356    if (hasVariants) buyLabel = 'From &#8377;' + book.variants[0].price;
357
358    // Build card
359    card.innerHTML =
360      '<div class="book-card-image" onclick="openProductDetail(\'' + book.id + '\')">' +
361        imageHtml +
362        ribbonHtml(book.id) +
363        (book.bestseller ? '<div class="book-card-badge-top"><span class="book-badge-bestseller">Bestseller</span></div>' : '') +
364        (stockHtml ? '<div class="book-card-badge">' + stockHtml + '</div>' : '') +
365      '</div>' +
366      '<div class="book-card-body">' +
367        '<div class="book-card-meta">' +
368          '<span class="book-category">' + formatCategory(book.category) + '</span>' +
369          langHtml +
370        '</div>' +
371        '<h3 class="book-card-title" onclick="openProductDetail(\'' + book.id + '\')">' + escapeHtml(book.title) + '</h3>' +
372        '<p class="book-author">' + escapeHtml(book.author) + '</p>' +
373        ratingHtml +
374        '<div class="book-card-price">' + buildPriceHtml(book) + '</div>' +
375        '<p class="book-desc">' + escapeHtml(shortDesc) +
376          (hasMore ? ' <a href="#" onclick="openProductDetail(\'' + book.id + '\');return false;" class="read-more-link">Read more</a>' : '') +
377        '</p>' +
378        '<div class="book-card-actions">' +
379          '<button class="btn btn-buy-quick" onclick="' + buyOnclick + '">' + buyLabel + '</button>' +
380          '<button class="btn btn-details" onclick="openProductDetail(\'' + book.id + '\')">Details</button>' +
381        '</div>' +
382      '</div>';
383
384    return card;
385  }
386
387  // ---- Product Detail Modal ----
388  window.openProductDetail = function(bookId) {
389    var book = null;
390    for (var i = 0; i < allBooks.length; i++) {
391      if (allBooks[i].id === bookId) { book = allBooks[i]; break; }
392    }
393    if (!book || !detailModal) return;
394
395    var imageHtml = book.cover
396      ? window.swCoverTag(booksPrefix, book.cover, book.title, 'large')
397      : '<img src="' + booksPrefix + 'images/placeholder-coming-soon.svg" alt="Coming Soon" style="width:100%;border-radius:10px;">';
398
399    var rating = getAverageRating(book.id);
400    var ratingHtml = rating.count > 0
401      ? '<div class="detail-rating"><span class="book-stars">' + renderStars(rating.avg) + '</span> <span>(' + rating.count + ' review' + (rating.count !== 1 ? 's' : '') + ')</span></div>'
402      : '<div class="detail-rating"><span class="book-stars book-stars-empty">' + renderStars(0) + '</span> <span>No reviews yet</span></div>';
403
404    var langHtml = book.language && book.language !== 'english'
405      ? '<span class="book-lang-badge">' + (languageNames[book.language] || book.language) + '</span>'
406      : '';
407
408    var specsText = buildSpecsHtml(book);
409    var isbnHtml = book.isbn ? '<tr><td>ISBN</td><td>' + book.isbn + '</td></tr>' : '';
410    var weightHtml = book.weight_kg ? '<tr><td>Weight</td><td>' + book.weight_kg + ' kg</td></tr>' : '';
411    var langRow = book.language ? '<tr><td>Language</td><td>' + (languageNames[book.language] || book.language) + '</td></tr>' : '';
412
413    var stockLabel = '';
414    if (book.quantity === 0) {
415      stockLabel = '<span class="book-stock preorder">Pre-Order &mdash; Print on Demand</span>';
416    } else if (book.quantity > 0 && book.quantity <= 5) {
417      stockLabel = '<span class="book-stock low">Only ' + book.quantity + ' left in stock</span>';
418    } else if (book.quantity > 5) {
419      stockLabel = '<span class="book-stock instock">In Stock</span>';
420    }
421
422    var buyLabel = book.quantity === 0 ? 'Pre-Order via UPI' : 'Buy Now — &#8377;' + book.price;
423
424    // The modal has room for words, so here the ribbon keeps a label beside it.
425    var inList = isInReadingList(book.id);
426    var rlClass = 'rl-btn' + (inList ? ' rl-active' : '');
427    var rlText = ribbonSvg(inList) +
428      '<span>' + (inList ? 'In your reading list' : 'Save to reading list') + '</span>';
429
430    // Reviews
431    var reviews = getReviews(book.id);
432    var reviewsHtml = '';
433    if (reviews.length > 0) {
434      reviewsHtml = '<div class="detail-reviews"><h4>Reviews</h4>';
435      reviews.slice(0, 5).forEach(function(r) {
436        reviewsHtml += '<div class="review-item"><div class="review-stars">' + renderStars(r.rating) + '</div>' +
437          '<p class="review-text">' + escapeHtml(r.text) + '</p>' +
438          '<p class="review-author">&mdash; ' + escapeHtml(r.name) + '</p></div>';
439      });
440      reviewsHtml += '</div>';
441    }
442
443    var content = document.getElementById('productDetailContent');
444    content.innerHTML =
445      '<div class="detail-layout">' +
446        '<div class="detail-image">' + imageHtml + '</div>' +
447        '<div class="detail-info">' +
448          '<span class="book-category">' + formatCategory(book.category) + '</span>' +
449          langHtml +
450          '<h2>' + escapeHtml(book.title) + '</h2>' +
451          '<p class="detail-author">by ' + escapeHtml(book.author) + '</p>' +
452          ratingHtml +
453          '<div class="detail-price-row">' + buildPriceHtml(book) + ' ' + stockLabel + (book.bestseller ? ' <span class="book-badge-bestseller">Amazon Bestseller</span>' : '') + '</div>' +
454          '<p class="detail-desc">' + escapeHtml(book.description || 'No description available.') + '</p>' +
455          (specsText ? '<table class="detail-specs"><tr><td>Format</td><td>' + specsText + '</td></tr>' +
456            isbnHtml + weightHtml + langRow + '</table>' : '') +
457          buildVariantsHtml(book) +
458          '<div class="detail-actions">' +
459            '<button class="btn btn-buy-now" id="detailBuyBtn" onclick="openUpiModal(\'' +
460              encodeURIComponent(book.title) + '\',' + book.price + ')">' + buyLabel + '</button>' +
461            '<button class="btn btn-add-cart" onclick="addToCart(\'' + book.id + '\',\'' + escapeHtml(book.title).replace(/'/g, "\\'") + '\',' + book.price + ')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 002 1.61h9.72a2 2 0 002-1.61L23 6H6"/></svg> Add to Cart</button>' +
462            '<button class="btn btn-write-review" onclick="openReviewForm(\'' +
463              encodeURIComponent(book.title) + '\',\'' + book.id + '\')">Write a Review</button>' +
464            '<button class="' + rlClass + '" data-book-id="' + book.id + '" onclick="toggleReadingList(\'' + book.id + '\')">' + rlText + '</button>' +
465          '</div>' +
466          (book.bulk_available ? '<div class="detail-bulk"><p>Need multiple copies? <a href="#" onclick="openBulkInquiry(\'' + encodeURIComponent(book.title) + '\');return false;">Request bulk pricing</a></p></div>' : '') +
467          reviewsHtml +
468        '</div>' +
469      '</div>';
470
471    // Share buttons
472    var bookUrl = window.location.origin + window.location.pathname + '#' + book.id;
473    var shareText = book.title + ' by ' + book.author + ' — Rs.' + book.price + ' at StoryWell Books';
474    content.innerHTML +=
475      '<div class="detail-share">' +
476        '<span class="detail-share-label">Share:</span>' +
477        '<a href="https://wa.me/?text=' + encodeURIComponent(shareText + ' ' + bookUrl) + '" target="_blank" rel="noopener" class="share-btn share-wa" title="WhatsApp">' +
478          '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z"/><path d="M12 0C5.373 0 0 5.373 0 12c0 2.917 1.043 5.59 2.775 7.669L.96 24l4.477-1.77A11.94 11.94 0 0012 24c6.627 0 12-5.373 12-12S18.627 0 12 0zm0 22c-2.487 0-4.784-.803-6.656-2.163l-.474-.353-2.648 1.048.89-2.597-.387-.503A9.956 9.956 0 012 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z"/></svg>
478' +
479        '</a>' +
480        '<a href="https://twitter.com/intent/tweet?text=' + encodeURIComponent(shareText) + '&url=' + encodeURIComponent(bookUrl) + '" target="_blank" rel="noopener" class="share-btn share-tw" title="Twitter/X">' +
481          '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>' +
482        '</a>' +
483        '<button onclick="copyBookLink(\'' + book.id + '\')" class="share-btn share-copy" title="Copy link">' +
484          '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>' +
485        '</button>' +
486      '</div>';
487
488    // Related books (same category, exclude current)
489    var related = allBooks.filter(function(b) {
490      return b.category === book.category && b.id !== book.id;
491    }).slice(0, 4);
492    if (related.length > 0) {
493      var relHtml = '<div class="detail-related"><h4>You might also like</h4><div class="related-grid">';
494      related.forEach(function(rb) {
495        var rCover = rb.cover
496          ? window.swCoverTag(booksPrefix, rb.cover, rb.title, 'thumb')
497          : '<img src="' + booksPrefix + 'images/placeholder-coming-soon.svg" alt="Coming Soon">';
498        relHtml += '<div class="related-card" onclick="openProductDetail(\'' + rb.id + '\')">' +
499          '<div class="related-cover">' + rCover + '</div>' +
500          '<p class="related-title">' + escapeHtml(rb.title) + '</p>' +
501          '<p class="related-price">&#8377;' + rb.price + '</p>' +
502        '</div>';
503      });
504      relHtml += '</div></div>';
505      content.innerHTML += relHtml;
506    }
507
508    // Track recently viewed
509    trackRecentlyViewed(book.id);
510
511    // Update URL hash
512    history.replaceState(null, '', '#' + book.id);
513
514    detailModal.classList.add('active');
515    document.body.style.overflow = 'hidden';
516  };
517
518  window.closeProductDetail = function() {
519    if (detailModal) {
520      detailModal.classList.remove('active');
521      document.body.style.overflow = '';
522      history.replaceState(null, '', window.location.pathname);
523    }
524  };
525
526  window.copyBookLink = function(bookId) {
527    var url = window.location.origin + window.location.pathname + '#' + bookId;
528    navigator.clipboard.writeText(url).then(function() {
529      var btn = document.querySelector('.share-copy');
530      if (btn) { btn.innerHTML = '&#10003; Copied!'; setTimeout(function() { btn.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>'; }, 2000); }
531    });
532  };
533
534  // ---- Hash routing ----
535  function handleHashRoute() {
536    var hash = window.location.hash.replace('#', '');
537    if (!hash) return;
538    // Check if it's a section
539    if (hash.indexOf('section-') === 0) {
540      var el = document.getElementById(hash);
541      if (el) el.scrollIntoView({ behavior: 'smooth' });
542      return;
543    }
544    // Otherwise try to open as a book
545    for (var i = 0; i < allBooks.length; i++) {
546      if (allBooks[i].id === hash) {
547        window.openProductDetail(hash);
548        return;
549      }
550    }
551  }
552
553  window.addEventListener('hashchange', function() {
554    handleHashRoute();
555  });
556
557  // ---- Recently viewed (localStorage) ----
558  function trackRecentlyViewed(bookId) {
559    var key = 'sw-recently-viewed';
560    var list = [];
561    try { list = JSON.parse(localStorage.getItem(key)) || []; } catch(e) {}
562    list = list.filter(function(id) { return id !== bookId; });
563    list.unshift(bookId);
564    if (list.length > 8) list = list.slice(0, 8);
565    localStorage.setItem(key, JSON.stringify(list));
566  }
567
568  function renderRecentlyViewed() {
569    var container = document.getElementById('recentlyViewed');
570    if (!container) return;
571    var list = [];
572    try { list = JSON.parse(localStorage.getItem('sw-recently-viewed')) || []; } catch(e) {}
573    if (list.length === 0) { container.style.display = 'none'; return; }
574    var recentBooks = [];
575    list.forEach(function(id) {
576      for (var i = 0; i < allBooks.length; i++) {
577        if (allBooks[i].id === id) { recentBooks.push(allBooks[i]); break; }
578      }
579    });
580    if (recentBooks.length === 0) { container.style.display = 'none'; return; }
581    container.style.display = 'block';
582    var grid = container.querySelector('.recent-grid');
583    if (!grid) return;
584    grid.innerHTML = '';
585    recentBooks.slice(0, 6).forEach(function(book) {
586      var cover = book.cover
587        ? window.swCoverTag(booksPrefix, book.cover, book.title, 'thumb')
588        : '<img src="' + booksPrefix + 'images/placeholder-coming-soon.svg" alt="Coming Soon">';
589      var card = document.createElement('div');
590      card.className = 'related-card';
591      card.onclick = function() { window.openProductDetail(book.id); };
592      card.innerHTML = '<div class="related-cover">' + cover + '</div>' +
593        '<p class="related-title">' + escapeHtml(book.title) + '</p>' +
594        '<p class="related-price">&#8377;' + book.price + '</p>';
595      grid.appendChild(card);
596    });
597  }
598
599  // ---- Search ----
600  var searchInput = document.getElementById('bookSearch');
601  if (searchInput) {
602    searchInput.addEventListener('input', function() {
603      var query = searchInput.value.toLowerCase().trim();
604      if (query.length === 0) {
605        renderSectionedView(allBooks);
606        return;
607      }
608      var results = allBooks.filter(function(b) {
609        return b.title.toLowerCase().indexOf(query) !== -1 ||
610               b.author.toLowerCase().indexOf(query) !== -1 ||
611               (b.description || '').toLowerCase().indexOf(query) !== -1 ||
612               (b.isbn || '').indexOf(query) !== -1 ||
613               b.category.indexOf(query) !== -1;
614      });
615      renderBooks(results);
616    });
617  }
618
619  if (detailModal) {
620    detailModal.addEventListener('click', function(e) {
621      if (e.target === detailModal) window.closeProductDetail();
622    });
623  }
624
625  // ---- Category filtering ----
626  if (categoryFilter) {
627    categoryFilter.addEventListener('click', function (e) {
628      if (e.target.tagName !== 'BUTTON') return;
629      categoryFilter.querySelectorAll('button').forEach(function (btn) { btn.classList.remove('active'); });
630      e.target.classList.add('active');
631      var cat = e.target.getAttribute('data-category');
632      if (cat === 'all') {
633        renderSectionedView(allBooks);
634      } else {
635        renderBooks(allBooks.filter(function (b) { return b.category === cat; }));
636      }
637    });
638  }
639
640  // ---- Promo code validation ----
641  window.applyPromoCode = function(inputId, amountId, originalPrice) {
642    var input = document.getElementById(inputId);
643    var amountEl = document.getElementById(amountId);
644    if (!input || !amountEl) return;
645
646    var code = input.value.trim().toUpperCase();
647    if (!code) { alert('Please enter a promo code.'); return; }
648
649    var now = new Date();
650    var matched = null;
651    for (var i = 0; i < promoCodes.length; i++) {
652      if (promoCodes[i].code.toUpperCase() === code) {
653        var promo = promoCodes[i];
654        if (promo.valid_from && new Date(promo.valid_from) > now) continue;
655        if (promo.valid_until && new Date(promo.valid_until) < now) continue;
656        if (promo.min_order && originalPrice < promo.min_order) {
657          alert('Minimum order of \u20B9' + promo.min_order + ' required for this code.');
658          return;
659        }
660        matched = promo;
661        break;
662      }
663    }
664
665    if (!matched) { alert('Invalid or expired promo code.'); return; }
666
667    var discount = matched.type === 'percent'
668      ? Math.round(originalPrice * matched.value / 100)
669      : matched.value;
670
671    var finalPrice = Math.max(0, originalPrice - discount);
672    currentFinalPrice = finalPrice;
673    currentPromo = matched;
674    amountEl.innerHTML = '&#8377;' + finalPrice +
675      ' <span style="text-decoration:line-through;color:var(--sw-muted);font-size:0.85em;">&#8377;' + originalPrice + '</span>' +
676      ' <span style="color:var(--sw-teal);font-size:0.85em;">(' + matched.description + ')</span>';
677
678    var orderAmount = document.getElementById('orderAmount');
679    if (orderAmount) orderAmount.value = finalPrice;
680
681    input.disabled = true;
682    input.style.opacity = '0.6';
683  };
684
685  // ---- Review form ----
686  window.openReviewForm = function(encodedTitle, bookId) {
687    var modal = document.getElementById('reviewModal');
688    if (modal) {
689      document.getElementById('reviewBookTitle').textContent = decodeURIComponent(encodedTitle);
690      document.getElementById('reviewBookId').value = bookId;
691      document.getElementById('reviewRating').value = '';
692      document.getElementById('reviewForm').reset();
693      modal.classList.add('active');
694      modal.querySelectorAll('.review-star-btn').forEach(function(b) { b.classList.remove('selected'); });
695    }
696  };
697
698  var reviewForm = document.getElementById('reviewForm');
699  if (reviewForm) {
700    var reviewStars = document.getElementById('reviewStarsInput');
701    if (reviewStars) {
702      reviewStars.addEventListener('click', function(e) {
703        if (e.target.tagName !== 'BUTTON') return;
704        var rating = parseInt(e.target.getAttribute('data-rating'));
705        document.getElementById('reviewRating').value = rating;
706        reviewStars.querySelectorAll('button').forEach(function(b) {
707          b.classList.toggle('selected', parseInt(b.getAttribute('data-rating')) <= rating);
708        });
709      });
710    }
711
712    reviewForm.addEventListener('submit', function(e) {
713      e.preventDefault();
714      var bookId = document.getElementById('reviewBookId').value;
715      var rating = parseInt(document.getElementById('reviewRating').value);
716      var name = document.getElementById('reviewName').value.trim();
717      var text = document.getElementById('reviewText').value.trim();
718
719      if (!rating || !name || !text) {
720        alert('Please fill in all fields and select a rating.');
721        return;
722      }
723
724      saveReview(bookId, { rating: rating, name: name, text: text, date: new Date().toISOString() });
725
726      // Also submit to Netlify
727      var formData = new FormData(reviewForm);
728      fetch('/', {
729        method: 'POST',
730        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
731        body: new URLSearchParams(formData).toString()
732      }).catch(function() {});
733
734      var modal = document.getElementById('reviewModal');
735      if (modal) modal.classList.remove('active');
736      alert('Thank you for your review!');
737
738      // Refresh the current view
739      renderSectionedView(allBooks);
740    });
741  }
742
743  // Close review modal
744  var reviewModalClose = document.getElementById('reviewModalClose');
745  if (reviewModalClose) {
746    reviewModalClose.addEventListener('click', function() {
747      document.getElementById('reviewModal').classList.remove('active');
748    });
749  }
750  var reviewModal = document.getElementById('reviewModal');
751  if (reviewModal) {
752    reviewModal.addEventListener('click', function(e) {
753      if (e.target === reviewModal) reviewModal.classList.remove('active');
754    });
755  }
756
757  // ---- Reading List Page ----
758  var rlGrid = document.getElementById('readingListGrid');
759  if (rlGrid) {
760    var list = getReadingList();
761    if (list.length === 0) {
762      rlGrid.innerHTML = '<p style="text-align:center;color:var(--sw-muted);padding:40px;">Your reading list is empty. Browse our <a href="books.html">catalog</a> to add books!</p>';
763    } else {
764      fetch(booksJsonPath)
765        .then(function(res) { return res.json(); })
766        .then(function(data) {
767          var books = data.books || data;
768          var listBooks = books.filter(function(b) { return list.indexOf(b.id) !== -1; });
769          rlGrid.innerHTML = '';
770          listBooks.forEach(function(book) { rlGrid.appendChild(createBookCard(book)); });
771        });
772    }
773  }
774
775  // ---- UPI Payment Modal (3-step: shipping details -> pay -> done) ----
776  var step1 = document.getElementById('upiStep1');
777  var step2 = document.getElementById('upiStep2');
778  var step3 = document.getElementById('upiStep3');
779  var toPaymentBtn = document.getElementById('toPaymentBtn');
780  var backToDetailsBtn = document.getElementById('backToDetailsBtn');
781  var orderForm = document.getElementById('orderForm');
782  var orderBook = document.getElementById('orderBook');
783  var orderAmount = document.getElementById('orderAmount');
784  var orderSummary = document.getElementById('orderSummary');
785  var payAmount = document.getElementById('payAmount');
786
787  window.currentModalPrice = 0;
788
789  var MONTHS_SHORT = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
790  function formatShortDate(d) {
791    return d.getDate() + ' ' + MONTHS_SHORT[d.getMonth()] + ' ' + d.getFullYear();
792  }
793
794  // Delivery window: 7-10 days from date of purchase
795  function deliveryWindow(from) {
796    var start = new Date(from.getTime() + 7 * 86400000);
797    var end = new Date(from.getTime() + 10 * 86400000);
798    return formatShortDate(start) + ' \u2013 ' + formatShortDate(end);
799  }
800
801  function itemsLabel() {
802    return currentOrderItems.map(function (it) {
803      return it.title + (it.qty > 1 ? ' x' + it.qty : '');
804    }).join(', ');
805  }
806
807  function resetModal() {
808    if (step1) step1.style.display = 'block';
809    if (step2) step2.style.display = 'none';
810    if (step3) step3.style.display = 'none';
811    if (orderForm) orderForm.reset();
812    var promoInput = document.getElementById('promoCodeInput');
813    if (promoInput) { promoInput.value = ''; promoInput.disabled = false; promoInput.style.opacity = '1'; }
814  }
815
816  // items: optional [{ title, qty, price }] \u2014 passed by the cart; single buys build their own
817  window.openUpiModal = function (encodedTitle, price, items) {
818    var title = decodeURIComponent(encodedTitle);
819    window.currentModalPrice = price;
820    currentFinalPrice = price;
821    currentPromo = null;
822    currentOrderItems = (items && items.length) ? items : [{ title: title, qty: 1, price: price }];
823    currentOrderFromCart = !!(items && items.length);
824    resetModal();
825
826    // Close detail modal if open
827    if (detailModal && detailModal.classList.contains('active')) {
828      detailModal.classList.remove('active');
829    }
830
831    modalBookTitle.textContent = title;
832    modalAmount.innerHTML = '&#8377;' + price;
833
834    if (orderBook) orderBook.value = title;
835    if (orderAmount) orderAmount.value = price;
836
837    upiModal.classList.add('active');
838    document.body.style.overflow = 'hidden';
839  };
840
841  // Build the payment screen from the current (post-promo) price
842  function showPaymentStep() {
843    var label = itemsLabel();
844    var tn = 'StoryWell: ' + label + (currentPromo ? ' [' + currentPromo.code + ']' : '');
845    var upiUrl = 'upi://pay?pa=' + encodeURIComponent(UPI_ID) +
846      '&pn=' + encodeURIComponent(UPI_NAME) +
847      '&am=' + currentFinalPrice +
848      '&cu=INR' +
849      '&tn=' + encodeURIComponent(tn);
850
851    if (modalUpiId) modalUpiId.textContent = UPI_ID;
852    if (payAmount) payAmount.innerHTML = '&#8377;' + currentFinalPrice;
853    if (orderSummary) {
854      orderSummary.textContent = label + ' \u2014 \u20B9' + currentFinalPrice +
855        (currentPromo ? ' (code: ' + currentPromo.code + ')' : '');
856    }
857    if (upiDeepLink) upiDeepLink.href = upiUrl;
858
859    if (typeof qrcode !== 'undefined' && modalQr) {
860      var qr = qrcode(0, 'M');
861      qr.addData(upiUrl);
862      qr.make();
863      modalQr.innerHTML = qr.createSvgTag(5);
864    } else if (modalQr) {
865      modalQr.innerHTML = '<p style="color:var(--sw-muted);font-size:0.9rem;">QR code unavailable. Use the button below.</p>';
866    }
867
868    if (step1) step1.style.display = 'none';
869    if (step2) step2.style.display = 'block';
870  }
871
872  if (toPaymentBtn) {
873    toPaymentBtn.addEventListener('click', function () {
874      // Shipping details must be valid before payment is shown
875      if (orderForm && !orderForm.reportValidity()) return;
876      showPaymentStep();
877    });
878  }
879
880  if (backToDetailsBtn) {
881    backToDetailsBtn.addEventListener('click', function () {
882      if (step2) step2.style.display = 'none';
883      if (step1) step1.style.display = 'block';
884    });
885  }
886
887  if (orderForm) {
888    orderForm.addEventListener('submit', function (e) {
889      e.preventDefault();
890      var formData = new FormData(orderForm);
891
892      // Generate order number: SWB-YYMMDD-XXXX
893      var now = new Date();
894      var yy = String(now.getFullYear()).slice(2);
895      var mm = String(now.getMonth() + 1).padStart(2, '0');
896      var dd = String(now.getDate()).padStart(2, '0');
897      var rand = String(Math.floor(1000 + Math.random() * 9000));
898      var orderNumber = 'SWB-' + yy + mm + dd + '-' + rand;
899
900      var expectedDelivery = deliveryWindow(now);
901      var itemsSummary = currentOrderItems.map(function (it) {
902        return it.title + ' x' + it.qty + ' @ \u20B9' + it.price;
903      }).join('; ');
904
905      formData.set('order_number', orderNumber);
906      formData.set('amount', currentFinalPrice);
907      formData.set('items', itemsSummary);
908      formData.set('expected_delivery', expectedDelivery);
909
910      // Also send to Google Sheets if configured (drives invoice emails)
911      if (SHEETS_ENDPOINT) {
912        var promoInput = document.getElementById('promoCodeInput');
913        fetch(SHEETS_ENDPOINT, {
914          method: 'POST',
915          mode: 'no-cors',
916          headers: { 'Content-Type': 'application/json' },
917          body: JSON.stringify({
918            order_number: orderNumber,
919            book: itemsLabel(),
920            amount: currentFinalPrice,
921            promo_code: (currentPromo && currentPromo.code) || (promoInput && promoInput.value) || '',
922            name: formData.get('name') || '',
923            email: formData.get('email') || '',
924            phone: formData.get('phone') || '',
925            address: formData.get('address') || '',
926            transaction_id: formData.get('transaction_id') || '',
927            notes: formData.get('notes') || '',
928            items: currentOrderItems,
929            order_date: now.toISOString(),
930            expected_delivery: expectedDelivery
931          })
932        }).catch(function() {});
933      }
934
935      // Submit to Netlify forms (primary)
936      fetch('/', {
937        method: 'POST',
938        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
939        body: new URLSearchParams(formData).toString()
940      }).then(function () {
941        if (step1) step1.style.display = 'none';
942        if (step2) step2.style.display = 'none';
943        if (step3) step3.style.display = 'block';
944        var orderNumEl = document.getElementById('orderNumberDisplay');
945        if (orderNumEl) orderNumEl.textContent = orderNumber;
946        var deliveryEl = document.getElementById('deliveryWindowDisplay');
947        if (deliveryEl) deliveryEl.textContent = 'Expected delivery: ' + expectedDelivery + ' (7\u201310 days from purchase)';
948        if (currentOrderFromCart && typeof window.clearCart === 'function') window.clearCart();
949      }).catch(function () {
950        alert('Something went wrong. Please email [email protected] with your order details.');
951      });
952    });
953  }
954
955  if (closeModal) {
956    closeModal.addEventListener('click', function () {
957      upiModal.classList.remove('active');
958      document.body.style.overflow = '';
959      resetModal();
960    });
961  }
962
963  if (upiModal) {
964    upiModal.addEventListener('click', function (e) {
965      if (e.target === upiModal) {
966        upiModal.classList.remove('active');
967        document.body.style.overflow = '';
968        resetModal();
969      }
970    });
971  }
972
973  document.addEventListener('keydown', function (e) {
974    if (e.key === 'Escape') {
975      if (detailModal && detailModal.classList.contains('active')) {
976        window.closeProductDetail();
977      } else if (upiModal && upiModal.classList.contains('active')) {
978        upiModal.classList.remove('active');
979        document.body.style.overflow = '';
980        resetModal();
981      } else if (reviewModal && reviewModal.classList.contains('active')) {
982        reviewModal.classList.remove('active');
983      }
984    }
985  });
986})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.