/** * お気に入り物件(localStorage・ログイン不要)。 * 一覧カード([data-listing-favorite])・物件詳細ページ・共通ヘッダー件数・ * お気に入り一覧ページ(/test/favorites/)から共通で利用する正本モジュール。 * * 旧実装 listing-favorites.js(キー: rakusumo_listing_favorites_v1、id/urlのみ保存)は * このモジュールに統合済み。旧キーのデータは初回読み込み時に自動移行する * (タイトル・画像・賃料・住所は移行時点では空文字のまま。次回そのカードを * 再度お気に入り登録し直すと最新情報で上書きされる)。 */ (function (root, factory) { if (typeof module === 'object' && module.exports) { module.exports = factory(); } else { root.PropertyFavorites = factory(); } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; var STORAGE_KEY = 'rakusumo_favorite_properties_v1'; var LEGACY_STORAGE_KEY = 'rakusumo_listing_favorites_v1'; var CHANGE_EVENT = 'favorites:changed'; var hasWindow = typeof window !== 'undefined'; var doc = hasWindow ? window.document : (typeof document !== 'undefined' ? document : null); var toastEl = null; var toastTimer = null; function ensureToastElement() { if (!doc || typeof doc.createElement !== 'function') return null; if (toastEl && toastEl.parentNode) return toastEl; toastEl = doc.getElementById('tpFavoritesToast'); if (toastEl) return toastEl; toastEl = doc.createElement('div'); toastEl.id = 'tpFavoritesToast'; toastEl.className = 'tp-favorites-toast'; toastEl.setAttribute('role', 'status'); toastEl.setAttribute('aria-live', 'polite'); toastEl.hidden = true; if (doc.body) doc.body.appendChild(toastEl); return toastEl; } function showToast(message) { var el = ensureToastElement(); if (!el) return; el.textContent = String(message || ''); el.hidden = false; el.classList.add('is-visible'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { el.classList.remove('is-visible'); el.hidden = true; }, 2500); } function safeGetItem(key) { try { if (!hasWindow || !window.localStorage) return null; return window.localStorage.getItem(key); } catch (_e) { return null; } } function safeSetItem(key, val) { try { if (!hasWindow || !window.localStorage) return false; window.localStorage.setItem(key, val); return true; } catch (_e) { /* 容量超過・プライベートモード等 */ return false; } } var didMigrate = false; /** 旧 listing-favorites.js のキーから id/url のみ引き継ぐ(他情報は空文字)。 */ function migrateLegacyStore() { if (didMigrate) return {}; didMigrate = true; var raw = safeGetItem(LEGACY_STORAGE_KEY); if (!raw) return {}; try { var parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; var out = {}; Object.keys(parsed).forEach(function (key) { var rec = parsed[key]; if (!rec || typeof rec !== 'object') return; var id = String(rec.id || '').trim(); if (!id || out[id]) return; out[id] = { propertyId: id, propertyUrl: String(rec.url || ''), title: '', imageUrl: '', rentText: '', address: '', savedAt: typeof rec.savedAt === 'number' ? rec.savedAt : Date.now(), }; }); if (Object.keys(out).length > 0) { safeSetItem(STORAGE_KEY, JSON.stringify(out)); } return out; } catch (_e) { return {}; } } function loadStore() { var raw = safeGetItem(STORAGE_KEY); if (raw == null) return migrateLegacyStore(); try { var parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed; } return {}; } catch (_e) { /* 破損データ: 壊さず空扱いにする(上書き保存はしない) */ return {}; } } function saveStore(map) { return safeSetItem(STORAGE_KEY, JSON.stringify(map || {})); } function normalizeId(id) { return String(id == null ? '' : id).trim(); } function getAll() { var map = loadStore(); return Object.keys(map) .map(function (k) { return map[k]; }) .filter(function (rec) { return rec && typeof rec === 'object'; }) .sort(function (a, b) { return (b.savedAt || 0) - (a.savedAt || 0); }); } function count() { return Object.keys(loadStore()).length; } function isFavorite(id) { id = normalizeId(id); if (!id) return false; var map = loadStore(); return !!map[id]; } function dispatchChange() { if (!hasWindow || typeof window.dispatchEvent !== 'function') return; try { var ev; if (typeof window.CustomEvent === 'function') { ev = new window.CustomEvent(CHANGE_EVENT, { detail: { count: count() } }); } else if (doc && typeof doc.createEvent === 'function') { ev = doc.createEvent('Event'); ev.initEvent(CHANGE_EVENT, true, true); } if (ev) window.dispatchEvent(ev); } catch (_e) { /* noop */ } } function add(data) { data = data || {}; var id = normalizeId(data.propertyId); if (!id) return false; var map = loadStore(); map[id] = { propertyId: id, propertyUrl: String(data.propertyUrl || ''), title: String(data.title || ''), imageUrl: String(data.imageUrl || ''), rentText: String(data.rentText || ''), address: String(data.address || ''), savedAt: Date.now(), }; saveStore(map); dispatchChange(); return true; } function remove(id) { id = normalizeId(id); if (!id) return false; var map = loadStore(); if (!map[id]) return false; delete map[id]; saveStore(map); dispatchChange(); return true; } function clearAll() { saveStore({}); dispatchChange(); } function dataFromButton(btn) { return { propertyId: btn.getAttribute('data-property-id') || '', propertyUrl: btn.getAttribute('data-property-url') || '', title: btn.getAttribute('data-property-title') || '', imageUrl: btn.getAttribute('data-property-image') || '', rentText: btn.getAttribute('data-property-rent') || '', address: btn.getAttribute('data-property-address') || '', }; } function syncButton(btn) { if (!btn) return; var id = normalizeId(btn.getAttribute('data-property-id')); var on = id !== '' && isFavorite(id); if (btn.classList) btn.classList.toggle('is-active', on); btn.setAttribute('aria-pressed', on ? 'true' : 'false'); btn.setAttribute('aria-label', on ? 'お気に入りを解除' : 'お気に入りに追加'); } function toggleFromButton(btn) { if (!btn) return; var id = normalizeId(btn.getAttribute('data-property-id')); if (!id) return; var wasFavorite = isFavorite(id); if (wasFavorite) { remove(id); showToast('お気に入りから削除しました'); } else { add(dataFromButton(btn)); showToast('お気に入りに追加しました'); } syncButton(btn); updateCountBadges(); } function initButtons(rootEl) { var scope = rootEl && typeof rootEl.querySelectorAll === 'function' ? rootEl : doc; if (!scope || typeof scope.querySelectorAll !== 'function') return; var list = scope.querySelectorAll('[data-listing-favorite]'); for (var i = 0; i < list.length; i++) { syncButton(list[i]); } } function updateCountBadges() { if (!doc || typeof doc.querySelectorAll !== 'function') return; var n = count(); var badges = doc.querySelectorAll('[data-favorites-count]'); for (var i = 0; i < badges.length; i++) { badges[i].textContent = n > 0 ? String(n) : ''; badges[i].hidden = n <= 0; } var labels = doc.querySelectorAll('[data-favorites-nav-label]'); for (var j = 0; j < labels.length; j++) { labels[j].textContent = n > 0 ? 'お気に入り(' + n + ')' : 'お気に入り'; } } var delegatedBound = false; function bindDelegatedEvents() { if (delegatedBound || !doc || typeof doc.addEventListener !== 'function') return; delegatedBound = true; doc.addEventListener('click', function (e) { var target = e.target; var btn = target && typeof target.closest === 'function' ? target.closest('[data-listing-favorite]') : null; if (!btn) return; e.preventDefault(); e.stopPropagation(); toggleFromButton(btn); }); if (hasWindow && typeof window.addEventListener === 'function') { window.addEventListener(CHANGE_EVENT, updateCountBadges); window.addEventListener('storage', function (e) { if (e && e.key && e.key !== STORAGE_KEY) return; updateCountBadges(); initButtons(doc); }); } } function init(rootEl) { bindDelegatedEvents(); initButtons(rootEl); updateCountBadges(); } if (doc) { var mountToast = function () { ensureToastElement(); init(doc); }; if (doc.readyState === 'loading') { doc.addEventListener('DOMContentLoaded', mountToast); } else { mountToast(); } } return { STORAGE_KEY: STORAGE_KEY, LEGACY_STORAGE_KEY: LEGACY_STORAGE_KEY, CHANGE_EVENT: CHANGE_EVENT, getAll: getAll, count: count, isFavorite: isFavorite, add: add, remove: remove, clearAll: clearAll, dataFromButton: dataFromButton, syncButton: syncButton, toggleFromButton: toggleFromButton, init: init, initButtons: initButtons, updateCountBadges: updateCountBadges, showToast: showToast, }; });