// UR租房助手 — main screen, API-driven const { useState: useStateMain, useEffect: useEffectMain, useMemo: useMemoMain, useCallback: useCallbackMain } = React; const BRAND_LOGO_URL = '/assets/logo-urrent.png'; const STATIC_REGION_LANDING_KEYS = new Set(['tokyo', 'osaka', 'fukuoka']); const REGION_SEO_NAMES = { hokkaido: '北海道', miyagi: '宫城县', tokyo: '东京', kanagawa: '神奈川', chiba: '千叶', saitama: '埼玉', ibaraki: '茨城', aichi: '爱知', mie: '三重', gifu: '岐阜', osaka: '大阪', hyogo: '兵库', kyoto: '京都', shiga: '滋贺', nara: '奈良', wakayama: '和歌山', okayama: '冈山', hiroshima: '广岛', yamaguchi: '山口', fukuoka: '福冈', }; const DEFAULT_REGION_KEY = 'tokyo'; function getInitialLocationFilter() { const fallback = { regions: [DEFAULT_REGION_KEY], areas: [] }; if (typeof window === 'undefined') return fallback; const validRegionKeys = new Set(REGIONS.flatMap(group => group.prefs.map(pref => pref.key))); const pathRegion = window.location.pathname.replace(/^\/+|\/+$/g, ''); if (pathRegion && validRegionKeys.has(pathRegion)) { return { regions: [pathRegion], areas: [] }; } const params = new URLSearchParams(window.location.search); const regionParam = params.get('region'); if (!regionParam) return fallback; const regions = regionParam .split(',') .map(item => item.trim()) .filter(item => validRegionKeys.has(item)); return regions.length > 0 ? { regions, areas: [] } : fallback; } function setMetaContent(selector, content) { const node = document.querySelector(selector); if (node && content) node.setAttribute('content', content); } function setLinkHref(selector, href) { const node = document.querySelector(selector); if (node && href) node.setAttribute('href', href); } function buildSeoPayload(selectedKeys) { const regions = selectedKeys.length > 0 ? selectedKeys : [DEFAULT_REGION_KEY]; if (regions.length === 1) { const regionKey = regions[0]; const regionName = REGION_SEO_NAMES[regionKey] || regionKey; return { title: `${regionName}UR租房 | UR租房帮手|日本UR公寓中文查询`, description: `查看${regionName}UR租房房源,支持按地区、租金和户型筛选,浏览户型图、物件照片与房间详情,帮助华人用户更高效找到合适的日本UR公寓。`, }; } return { title: '日本UR租房 | UR租房帮手|东京·大阪·福冈等地区UR公寓查询', description: 'UR租房帮手为华人用户提供日本UR租房信息查询,覆盖东京、大阪、福冈等地区,支持按地区、租金、房型筛选,查看房间详情、户型图与物件照片。', }; } function buildCanonicalUrl(selectedKeys) { const base = new URL(window.location.origin); if (selectedKeys.length === 1 && STATIC_REGION_LANDING_KEYS.has(selectedKeys[0])) { base.pathname = `/${selectedKeys[0]}/`; return base.toString(); } base.pathname = '/'; if (selectedKeys.length === 1) { base.searchParams.set('region', selectedKeys[0]); } else if (selectedKeys.length > 1) { base.searchParams.set('region', selectedKeys.join(',')); } return base.toString(); } function App() { const [tab, setTab] = useStateMain('listings'); const [locSheetOpen, setLocSheetOpen] = useStateMain(false); const [rentSheetOpen, setRentSheetOpen] = useStateMain(false); const [districtSheetOpen, setDistrictSheetOpen] = useStateMain(false); const [locationFilter, setLocationFilter] = useStateMain(getInitialLocationFilter); const [selectedDistricts, setSelectedDistricts] = useStateMain([]); const [rent, setRent] = useStateMain({ min: 0, max: MAX_RENT }); const [sort, setSort] = useStateMain('recommend'); const [areasByRegion, setAreasByRegion] = useStateMain({}); const [showRefreshHint, setShowRefreshHint] = useStateMain(false); const [properties, setProperties] = useStateMain([]); const [loading, setLoading] = useStateMain(false); const [error, setError] = useStateMain(null); const [activeRoom, setActiveRoom] = useStateMain(null); // { property, room } const openRoomByKeys = useCallbackMain(async (propertyKey, roomKey) => { if (!propertyKey || !roomKey) return; try { const property = await apiFetch('/api/v1/properties/' + encodeURIComponent(propertyKey)); const rooms = property.rooms || []; const room = rooms.find((item) => item.roomKey === roomKey || item.id === roomKey || item.canonicalRoomId === roomKey); if (!room) return; setTab('listings'); setActiveRoom({ property, room }); } catch (err) { console.warn('open room by keys failed', err); } }, []); const selectedKeys = locationFilter.regions; const selectedAreaKeys = locationFilter.areas; const ensureAreas = useCallbackMain(async (regionKey) => { if (!regionKey) return; const current = areasByRegion[regionKey]; if (current?.loading || current?.loaded) return; setAreasByRegion(prev => ({ ...prev, [regionKey]: { data: prev[regionKey]?.data || [], loading: true, loaded: false, error: null, }, })); try { const res = await apiFetch('/api/v1/areas?regionKey=' + encodeURIComponent(regionKey)); const data = Array.isArray(res) ? res : (res.data || res.areas || res.items || []); setAreasByRegion(prev => ({ ...prev, [regionKey]: { data, loading: false, loaded: true, error: null, }, })); } catch (err) { setAreasByRegion(prev => ({ ...prev, [regionKey]: { data: prev[regionKey]?.data || [], loading: false, loaded: false, error: err.message || '区域加载失败', }, })); } }, [areasByRegion]); useEffectMain(() => { selectedKeys.forEach(ensureAreas); }, [selectedKeys, ensureAreas]); const loadProperties = useCallbackMain(async () => { if (selectedKeys.length === 0) { setProperties([]); return; } setLoading(true); setError(null); try { const results = await Promise.all( selectedKeys.map(k => apiFetch('/api/v1/properties?regionKey=' + encodeURIComponent(k)) .catch(err => { console.warn('region ' + k + ' failed', err); return null; })) ); const merged = []; const seen = new Set(); let anyOk = false; for (const res of results) { if (!res) continue; anyOk = true; const arr = Array.isArray(res) ? res : (res.data || res.properties || res.items || []); for (const p of arr) { const key = p.propertyKey || p.id; if (seen.has(key)) continue; seen.add(key); merged.push(p); } } if (!anyOk) throw new Error('API 无法从浏览器访问(CORS 未开启)。请在 api.repilot.jp 添加 Access-Control-Allow-Origin 响应头。'); setProperties(merged); return true; } catch (e) { setError(e.message || '加载失败'); setProperties([]); return false; } finally { setLoading(false); } }, [selectedKeys]); useEffectMain(() => { loadProperties(); }, [loadProperties]); useEffectMain(() => { if (!showRefreshHint) return undefined; const timer = window.setTimeout(() => setShowRefreshHint(false), 1800); return () => window.clearTimeout(timer); }, [showRefreshHint]); useEffectMain(() => { const params = new URLSearchParams(window.location.search); const propertyKey = params.get('propertyKey'); const roomKey = params.get('roomKey'); const open = params.get('open'); if (open === 'room' && propertyKey && roomKey) { openRoomByKeys(propertyKey, roomKey); } }, [openRoomByKeys]); useEffectMain(() => { const normalizedRegions = selectedKeys.length > 0 ? selectedKeys : [DEFAULT_REGION_KEY]; const payload = buildSeoPayload(normalizedRegions); const canonicalUrl = buildCanonicalUrl(selectedKeys); document.title = payload.title; setMetaContent('meta[name="description"]', payload.description); setMetaContent('meta[property="og:title"]', payload.title); setMetaContent('meta[property="og:description"]', payload.description); setMetaContent('meta[property="og:url"]', canonicalUrl); setMetaContent('meta[name="twitter:title"]', payload.title); setMetaContent('meta[name="twitter:description"]', payload.description); setLinkHref('link[rel="canonical"]', canonicalUrl); window.history.replaceState({}, '', canonicalUrl); }, [selectedKeys]); const handleRefresh = useCallbackMain(async () => { const ok = await loadProperties(); if (ok) setShowRefreshHint(true); }, [loadProperties]); const filtered = useMemoMain(() => { let list = properties.filter(p => { const parsed = parseRentRange(p.rent); // property matches if its min rent ≤ rent.max and max rent ≥ rent.min if (parsed.max < rent.min) return false; if (parsed.min > rent.max) return false; if (selectedAreaKeys.length > 0 && !selectedAreaKeys.includes(p.areaKey)) return false; if (selectedDistricts.length > 0 && !selectedDistricts.includes(normalizeDistrictName(p.skcs || p.districtName))) return false; return (p.activeRoomCount ?? (p.rooms || []).length) > 0; }); if (sort === 'cheap') list.sort((a, b) => parseRentRange(a.rent).min - parseRentRange(b.rent).min); if (sort === 'expensive') list.sort((a, b) => parseRentRange(b.rent).max - parseRentRange(a.rent).max); return list; }, [properties, rent, sort, selectedAreaKeys, selectedDistricts]); const totalRooms = filtered.reduce((s, p) => s + (p.activeRoomCount || 0), 0); const districtOptions = useMemoMain(() => ( selectedAreaKeys.length > 0 ? buildDistrictOptions( properties, selectedAreaKeys, areasByRegion, (property) => property.skcs || property.districtName || '' ) : [] ), [properties, selectedAreaKeys, areasByRegion]); useEffectMain(() => { const validDistricts = new Set(districtOptions.map((item) => item.key)); setSelectedDistricts((prev) => { const next = prev.filter((item) => validDistricts.has(item)); return next.length === prev.length ? prev : next; }); }, [districtOptions]); const labelOf = (key) => { for (const r of REGIONS) { const m = r.prefs.find(p => p.key === key); if (m) return m.label; } return key; }; const areaLabelOf = (areaKey) => { const regionKey = areaKey.split('__')[0]; const area = areasByRegion[regionKey]?.data?.find(item => item.areaKey === areaKey); return area?.name || areaKey; }; const locLabel = selectedAreaKeys.length > 0 ? selectedAreaKeys.length === 1 ? areaLabelOf(selectedAreaKeys[0]) : `${areaLabelOf(selectedAreaKeys[0])} 等${selectedAreaKeys.length}个片区` : selectedKeys.length === 0 ? '位置' : selectedKeys.length === 1 ? labelOf(selectedKeys[0]) : `${labelOf(selectedKeys[0])} 等${selectedKeys.length}个`; const rentLabel = (rent.min === 0 && rent.max === MAX_RENT) ? '租金' : rent.max >= MAX_RENT ? `¥${manYen(rent.min)}〜` : rent.min === 0 ? `〜¥${manYen(rent.max)}` : `¥${manYen(rent.min)}〜${manYen(rent.max)}`; const districtLabel = selectedDistricts.length > 0 ? selectedDistricts.length === 1 ? selectedDistricts[0] : `${selectedDistricts[0]} 等${selectedDistricts.length}个区域` : '区域'; return (
{/* Top bar */}
UR租房帮手 logo
UR租房帮手
{loading ? '加载中…' : '日本UR租赁住宅'}
{showRefreshHint && (
房源已更新
)} {tab === 'listings' ? (
{selectedAreaKeys.length > 0 && districtOptions.length > 0 && ( )}
{filtered.length} 个房源 ·{totalRooms}
{loading && properties.length === 0 ? ( <> ) : error ? (
加载失败
{error}
) : filtered.length === 0 ? (
没有找到符合条件的房源
请尝试调整地区或租金范围
) : ( filtered.map(p => ( setActiveRoom({ property, room })} /> )) )} {filtered.length > 0 && (
— 共 {filtered.length} 个房源 —
)}
) : ( )} {activeRoom && ( setActiveRoom(null)} /> )} setLocSheetOpen(false)} selectedRegions={selectedKeys} selectedAreas={selectedAreaKeys} onChange={setLocationFilter} areasByRegion={areasByRegion} ensureAreas={ensureAreas} countMode="rooms"/> setRentSheetOpen(false)} value={rent} onChange={setRent}/> setDistrictSheetOpen(false)} options={districtOptions} selectedDistricts={selectedDistricts} onChange={setSelectedDistricts} countMode="rooms" /> {!activeRoom && (
)}
); } function SkeletonCard() { return (
); } const appStyles = { shell: { width: '100%', height: '100%', background: '#FAF8F5', display: 'flex', flexDirection: 'column', fontFamily: '"Noto Sans JP", "Hiragino Sans", system-ui, sans-serif', color: '#1A1A1A', position: 'relative', overflow: 'hidden' }, topBar: { padding: 'calc(12px + env(safe-area-inset-top, 0px)) 18px 10px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#FAF8F5', borderBottom: '1px solid #EDE6D9' }, brand: { display: 'flex', alignItems: 'center', gap: 10 }, logoMark: { width: 40, height: 40, borderRadius: 12, overflow: 'hidden', background: '#F4EADB', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }, logoImg: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' }, brandTitle: { fontSize: 15, fontWeight: 700, color: '#1A1A1A', letterSpacing: '0.01em', lineHeight: 1.2 }, brandSub: { fontSize: 10, color: '#8A7F72', fontWeight: 500, marginTop: 1, letterSpacing: '0.04em' }, iconBtn: { width: 36, height: 36, borderRadius: 18, border: '1px solid #EDE6D9', background: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }, refreshSpin: { display: 'flex', animation: 'spin 900ms linear infinite' }, refreshToast: { position: 'absolute', top: 'calc(64px + env(safe-area-inset-top, 0px))', left: '50%', transform: 'translateX(-50%)', zIndex: 40, padding: '8px 14px', borderRadius: 999, background: 'rgba(26,26,26,0.88)', color: '#FFFFFF', fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', boxShadow: '0 8px 24px rgba(20,18,14,0.16)', animation: 'fadeIn 180ms ease', }, content: { flex: 1, overflowY: 'auto', overflowX: 'hidden', paddingBottom: 'calc(110px + env(safe-area-inset-bottom, 0px))' }, filterRow: { display: 'flex', flexWrap: 'wrap', gap: 8, padding: '12px 16px 8px', position: 'sticky', top: 0, background: '#FAF8F5', zIndex: 5 }, filterPill: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '9px 14px', borderRadius: 999, border: '1px solid #E4DFD6', fontSize: 13, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer', transition: 'all 150ms ease' }, resultsMeta: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 18px 10px' }, resultsCount: { fontSize: 12, color: '#6B6258', fontWeight: 500 }, resultsDivider: { margin: '0 6px', color: '#C4BBAA' }, sortWrap: { display: 'flex', alignItems: 'center', gap: 4, padding: '5px 8px 5px 10px', borderRadius: 999, background: '#F4EFE4' }, sortSelect: { border: 'none', background: 'transparent', fontSize: 12, color: '#6B6258', fontFamily: 'inherit', fontWeight: 500, cursor: 'pointer', appearance: 'none', paddingRight: 2 }, cardsWrap: { padding: '4px 14px 16px' }, endHint: { textAlign: 'center', fontSize: 11, color: '#B0A695', padding: '16px 0 8px', letterSpacing: '0.1em' }, empty: { textAlign: 'center', padding: '60px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }, emptyIcon: { width: 72, height: 72, borderRadius: 36, background: '#F4EFE4', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 4 }, emptyTitle: { fontSize: 15, fontWeight: 600, color: '#1A1A1A' }, emptySub: { fontSize: 12, color: '#8A7F72', maxWidth: 240 }, retryBtn: { marginTop: 8, padding: '10px 20px', borderRadius: 10, border: '1px solid #E4DFD6', background: '#FFFFFF', color: '#1A1A1A', fontSize: 13, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer' }, tabBar: { position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 80, background: 'rgba(250, 248, 245, 0.92)', backdropFilter: 'blur(16px) saturate(180%)', WebkitBackdropFilter: 'blur(16px) saturate(180%)', borderTop: '1px solid #EDE6D9', display: 'grid', gridTemplateColumns: '1fr 1fr', padding: '8px 12px calc(16px + env(safe-area-inset-bottom, 0px))' }, tab: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, padding: '6px', background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 11, fontFamily: 'inherit', letterSpacing: '0.02em' }, comingWrap: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px 20px calc(120px + env(safe-area-inset-bottom, 0px))' }, comingCard: { width: '100%', background: '#FFFFFF', borderRadius: 20, padding: '32px 24px 24px', textAlign: 'center', border: '1px solid #EDE6D9', boxShadow: '0 2px 10px rgba(60, 50, 30, 0.04)' }, comingIconRing: { width: 92, height: 92, margin: '0 auto 18px', position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }, comingIconInner: { width: 64, height: 64, borderRadius: 32, background: 'linear-gradient(135deg, #FBEFE6, #F4EADB)', display: 'flex', alignItems: 'center', justifyContent: 'center', border: '1px solid #F4D9C5' }, ringDot1: { position: 'absolute', top: 6, right: 10, width: 10, height: 10, borderRadius: 5, background: '#C6572B', opacity: 0.85 }, ringDot2: { position: 'absolute', bottom: 10, left: 4, width: 6, height: 6, borderRadius: 3, background: '#4D8B5E', opacity: 0.7 }, comingBadge: { display: 'inline-block', padding: '3px 10px', borderRadius: 999, background: '#F4EADB', color: '#8a7256', fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', marginBottom: 10 }, comingTitle: { fontSize: 22, fontWeight: 700, color: '#1A1A1A', letterSpacing: '0.02em' }, comingTitleJp: { fontSize: 13, color: '#8A7F72', fontWeight: 500, marginTop: 2, letterSpacing: '0.15em' }, comingDesc: { fontSize: 13, color: '#6B6258', lineHeight: 1.7, margin: '14px 0 20px' }, comingFeatures: { display: 'flex', flexDirection: 'column', gap: 10, textAlign: 'left', padding: '14px 4px', borderTop: '1px dashed #EDE6D9', borderBottom: '1px dashed #EDE6D9', marginBottom: 18 }, comingFeat: { display: 'flex', gap: 10, alignItems: 'flex-start' }, comingFeatDot: { width: 6, height: 6, borderRadius: 3, background: '#C6572B', marginTop: 7, flexShrink: 0 }, comingFeatT: { fontSize: 13, fontWeight: 600, color: '#1A1A1A' }, comingFeatD: { fontSize: 11, color: '#8A7F72', marginTop: 2 }, comingCta: { width: '100%', padding: '14px', borderRadius: 12, border: 'none', background: '#1A1A1A', color: '#FAF8F5', fontSize: 14, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer', letterSpacing: '0.02em' }, }; Object.assign(window, { App });