// UR租房助手 — data, API client, icons const { useState, useEffect, useRef, useMemo } = React; // ───────────────────────────────────────────────────────────── // API client // ───────────────────────────────────────────────────────────── const API_BASE = 'https://api.repilot.jp'; const API_TOKEN = '71d7f127d638d19071af0fb6caaa4e53a8e20d4c39161aa5'; const SUBSCRIBE_API_BASE = (() => { const host = window.location.hostname; if (host === '127.0.0.1' || host === 'localhost') return 'http://127.0.0.1:4175'; return ''; })(); const WATCH_SESSION_STORAGE_KEY = 'ur_watch_session_v1'; async function apiFetch(path) { const res = await fetch(API_BASE + path, { headers: { Authorization: 'Bearer ' + API_TOKEN }, }); if (!res.ok) throw new Error('API ' + res.status); return res.json(); } async function subscribeApiFetch(path, options = {}) { const res = await fetch(SUBSCRIBE_API_BASE + '/api/subscriptions' + path, { method: options.method || 'GET', headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: 'Bearer ' + options.token } : {}), ...(options.headers || {}), }, body: options.body ? JSON.stringify(options.body) : undefined, }); const text = await res.text(); const data = text ? JSON.parse(text) : null; if (!res.ok) throw new Error(data?.error || 'SUBSCRIBE_API_' + res.status); return data; } function getWatchSession() { try { const raw = localStorage.getItem(WATCH_SESSION_STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (error) { return null; } } function setWatchSession(session) { localStorage.setItem(WATCH_SESSION_STORAGE_KEY, JSON.stringify(session)); } function clearWatchSession() { localStorage.removeItem(WATCH_SESSION_STORAGE_KEY); } function formatDateTimeZh(value) { if (!value) return '—'; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(date); } function normalizeDistrictName(value) { return String(value || '').trim(); } function splitAreaRange(range) { return String(range || '') .split(/[、,,]/) .map((item) => item.trim()) .filter(Boolean); } function buildDistrictOptions(items, selectedAreaKeys, areasByRegion, getDistrictName) { const areaSet = new Set(selectedAreaKeys || []); const preferredOrder = new Map(); let nextOrder = 0; for (const areaKey of selectedAreaKeys || []) { const regionKey = String(areaKey).split('__')[0]; const area = areasByRegion?.[regionKey]?.data?.find((item) => item.areaKey === areaKey); for (const district of splitAreaRange(area?.range)) { const normalized = normalizeDistrictName(district); if (!normalized || preferredOrder.has(normalized)) continue; preferredOrder.set(normalized, nextOrder++); } } const grouped = new Map(); for (const item of items || []) { if (areaSet.size > 0 && !areaSet.has(item.areaKey)) continue; const district = normalizeDistrictName(getDistrictName(item)); if (!district) continue; const current = grouped.get(district) || { key: district, label: district, propertyCount: 0, roomCount: 0, }; current.propertyCount += 1; current.roomCount += item.activeRoomCount ?? item.roomCount ?? (item.rooms || []).length ?? 0; grouped.set(district, current); } return Array.from(grouped.values()).sort((a, b) => { const orderA = preferredOrder.has(a.key) ? preferredOrder.get(a.key) : Number.MAX_SAFE_INTEGER; const orderB = preferredOrder.has(b.key) ? preferredOrder.get(b.key) : Number.MAX_SAFE_INTEGER; if (orderA !== orderB) return orderA - orderB; return a.label.localeCompare(b.label, 'ja'); }); } // ───────────────────────────────────────────────────────────── // Region groupings (UR's fixed mapping, adapted for mobile) // Maps Chinese/Japanese display labels to regionKey used by API. // ───────────────────────────────────────────────────────────── const REGIONS = [ { name: '北海道・東北', prefs: [ { label: '北海道', key: 'hokkaido' }, { label: '宮城県', key: 'miyagi' }, ]}, { name: '関東', prefs: [ { label: '東京都', key: 'tokyo' }, { label: '神奈川県', key: 'kanagawa' }, { label: '千葉県', key: 'chiba' }, { label: '埼玉県', key: 'saitama' }, { label: '茨城県', key: 'ibaraki' }, ]}, { name: '東海', prefs: [ { label: '愛知県', key: 'aichi' }, { label: '三重県', key: 'mie' }, { label: '岐阜県', key: 'gifu' }, ]}, { name: '関西', prefs: [ { label: '大阪府', key: 'osaka' }, { label: '兵庫県', key: 'hyogo' }, { label: '京都府', key: 'kyoto' }, { label: '滋賀県', key: 'shiga' }, { label: '奈良県', key: 'nara' }, { label: '和歌山県', key: 'wakayama' }, ]}, { name: '中国', prefs: [ { label: '岡山県', key: 'okayama' }, { label: '広島県', key: 'hiroshima' }, { label: '山口県', key: 'yamaguchi' }, ]}, { name: '九州', prefs: [ { label: '福岡県', key: 'fukuoka' }, ]}, ]; // ───────────────────────────────────────────────────────────── // Rent parsing: "44,000円~51,200円" → { min, max } // ───────────────────────────────────────────────────────────── function parseRentRange(s) { if (!s) return { min: 0, max: 0 }; const nums = s.replace(/,/g, '').match(/\d+/g); if (!nums) return { min: 0, max: 0 }; const ns = nums.map(n => parseInt(n, 10)); return { min: ns[0], max: ns[ns.length - 1] }; } // "万円" formatting const manYen = (n) => { if (!n) return '0'; if (n >= 10000) { const man = n / 10000; return man % 1 === 0 ? `${man}万` : `${man.toFixed(1)}万`; } return n.toLocaleString('ja-JP'); }; // Resolve image URL (prefer local hosted asset if relative) function resolveImg(url) { if (!url) return null; if (url.startsWith('/')) return API_BASE + url; return url; } // ───────────────────────────────────────────────────────────── // Icons // ───────────────────────────────────────────────────────────── const Icon = { Chevron: ({ size = 14, dir = 'down', color = 'currentColor' }) => ( ), Close: ({ size = 18, color = 'currentColor' }) => ( ), Location: ({ size = 14, color = 'currentColor' }) => ( ), Yen: ({ size = 14, color = 'currentColor' }) => ( ), Home: ({ size = 22, color = 'currentColor', fill = false }) => ( ), Bell: ({ size = 22, color = 'currentColor', fill = false }) => ( ), Search: ({ size = 16, color = 'currentColor' }) => ( ), Sort: ({ size = 14, color = 'currentColor' }) => ( ), Refresh: ({ size = 14, color = 'currentColor' }) => ( ), Mail: ({ size = 14, color = 'currentColor' }) => ( ), Trash: ({ size = 14, color = 'currentColor' }) => ( ), Clock: ({ size = 14, color = 'currentColor' }) => ( ), Train: ({ size = 12, color = 'currentColor' }) => ( ), }; Object.assign(window, { REGIONS, Icon, apiFetch, subscribeApiFetch, parseRentRange, manYen, resolveImg, API_BASE, SUBSCRIBE_API_BASE, getWatchSession, setWatchSession, clearWatchSession, formatDateTimeZh, normalizeDistrictName, buildDistrictOptions, });