// 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,
});