/** * トップ画面: パンフレットを探す。 * * - キーワード・都道府県・カテゴリで絞り込み * - 「現在地から探す」で近い順に並べ、その地点を配信範囲に含む優先枠(PR)を上に出す * - リスト / 地図 の切り替え(地図は表示範囲が変わるたびに /api/v1/pamphlets/map を読む) * * iOS アプリと同じ公開 API だけを使う。画面だけの特別な口は作らない。 */ (function renderCatalogHomeApp() { 'use strict'; const { useState, useEffect, useCallback, useRef } = React; const rootElement = document.getElementById('catalog-home-root'); const searchEndpointUrl = rootElement.dataset.searchEndpoint; const mapEndpointUrl = rootElement.dataset.mapEndpoint; const reelsUrl = rootElement.dataset.reelsUrl; const categoryLabelsByKey = JSON.parse(rootElement.dataset.categories); const prefectureNames = JSON.parse(rootElement.dataset.prefectures); const JAPAN_CENTER = [36.2, 138.25]; const KIND_OPTIONS = [ { key: '', label: 'すべて' }, { key: 'pamphlet', label: 'パンフレット' }, { key: 'poster', label: 'ポスター' }, ]; function buildQueryString(searchConditions) { const queryParameters = new URLSearchParams(); Object.entries(searchConditions).forEach(([parameterName, parameterValue]) => { if (parameterValue !== null && parameterValue !== undefined && parameterValue !== '') { queryParameters.set(parameterName, parameterValue); } }); return queryParameters.toString(); } function formatEventPeriod(startsOn, endsOn) { if (!startsOn) return null; const formatDate = (isoDate) => { const [, month, day] = isoDate.split('-'); return `${Number(month)}/${Number(day)}`; }; return endsOn && endsOn !== startsOn ? `${formatDate(startsOn)}〜${formatDate(endsOn)}` : formatDate(startsOn); } function PamphletCard({ pamphlet, isPromoted }) { const eventPeriod = formatEventPeriod(pamphlet.starts_on, pamphlet.ends_on); return (
{pamphlet.cover_image_url && }
{pamphlet.is_ongoing && 開催中} {isPromoted && PR}

{pamphlet.title}

{pamphlet.venue && (
{pamphlet.venue.prefecture}・{pamphlet.venue.name}
)}
{pamphlet.kind === 'poster' && ポスター} {pamphlet.category_label} {eventPeriod && {eventPeriod}} {pamphlet.distance_km !== undefined && {pamphlet.distance_km}km}
); } function PamphletMap({ searchConditions, currentLocation }) { const mapContainerRef = useRef(null); const leafletMapRef = useRef(null); const markerLayerRef = useRef(null); const searchConditionsRef = useRef(searchConditions); searchConditionsRef.current = searchConditions; const loadPinsInView = useCallback(async () => { const leafletMap = leafletMapRef.current; if (!leafletMap) return; const visibleBounds = leafletMap.getBounds(); const mapResponse = await requestJson(`${mapEndpointUrl}?${buildQueryString({ south: visibleBounds.getSouth().toFixed(5), west: visibleBounds.getWest().toFixed(5), north: visibleBounds.getNorth().toFixed(5), east: visibleBounds.getEast().toFixed(5), })}`); const { category: selectedCategory, kind: selectedKind } = searchConditionsRef.current; markerLayerRef.current.clearLayers(); mapResponse.data .filter((pamphlet) => !selectedCategory || pamphlet.category === selectedCategory) .filter((pamphlet) => !selectedKind || pamphlet.kind === selectedKind) .forEach((pamphlet) => { const popupElement = document.createElement('div'); const titleLink = document.createElement('a'); titleLink.href = pamphlet.viewer_url; titleLink.textContent = pamphlet.title; titleLink.style.fontWeight = '700'; const venueLine = document.createElement('div'); venueLine.textContent = pamphlet.venue.name; popupElement.append(titleLink, venueLine); L.marker([pamphlet.venue.latitude, pamphlet.venue.longitude]) .bindPopup(popupElement) .addTo(markerLayerRef.current); }); }, []); useEffect(() => { const leafletMap = L.map(mapContainerRef.current).setView( currentLocation ? [currentLocation.latitude, currentLocation.longitude] : JAPAN_CENTER, currentLocation ? 12 : 5, ); L.tileLayer(PamphletHubMap.tileUrl, { attribution: PamphletHubMap.attribution, maxZoom: 18 }).addTo(leafletMap); markerLayerRef.current = L.layerGroup().addTo(leafletMap); leafletMapRef.current = leafletMap; if (currentLocation) { L.circleMarker([currentLocation.latitude, currentLocation.longitude], { radius: 8, color: '#2b3a67', fillOpacity: 0.8 }) .bindTooltip('現在地') .addTo(leafletMap); } leafletMap.on('moveend', loadPinsInView); loadPinsInView(); return () => leafletMap.remove(); }, [currentLocation, loadPinsInView]); useEffect(() => { loadPinsInView(); }, [searchConditions.category, searchConditions.kind, loadPinsInView]); return
; } const FEATURE_INTERVAL_MS = 6000; const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; /** * トップの「いま注目の催し」。雑誌の表紙のように 1 件を大きく見せ、数秒ごとに次へ切り替える。 * 表紙は切り抜かず紙のまま見せる。マウスを乗せている間・操作中は止める。 */ function FeaturedHero({ featuredPamphlets }) { const [currentFeatureIndex, setCurrentFeatureIndex] = useState(0); const [isPaused, setIsPaused] = useState(false); const featureCount = featuredPamphlets.length; const showFeature = useCallback((targetIndex) => { setCurrentFeatureIndex(((targetIndex % featureCount) + featureCount) % featureCount); }, [featureCount]); useEffect(() => { if (featureCount < 2 || isPaused || prefersReducedMotion) return undefined; const advanceTimer = window.setTimeout(() => showFeature(currentFeatureIndex + 1), FEATURE_INTERVAL_MS); return () => window.clearTimeout(advanceTimer); }, [currentFeatureIndex, featureCount, isPaused, showFeature]); if (featureCount === 0) { return (

全国のパンフレット・ポスター・チラシを、無料でスライド閲覧。

); } const featuredPamphlet = featuredPamphlets[Math.min(currentFeatureIndex, featureCount - 1)]; const eventPeriod = formatEventPeriod(featuredPamphlet.starts_on, featuredPamphlet.ends_on); return (
setIsPaused(true)} onMouseLeave={() => setIsPaused(false)} onFocus={() => setIsPaused(true)} onBlur={() => setIsPaused(false)} >

全国のパンフレット・ポスター・チラシを、無料でスライド閲覧

{featuredPamphlet.is_ongoing ? 'いま開催中' : 'いま注目の催し'}

{featuredPamphlet.is_ongoing && 開催中} {featuredPamphlet.kind === 'poster' && ポスター} {featuredPamphlet.category_label}

{featuredPamphlet.title}

{eventPeriod && <>
開催
{eventPeriod}
} {featuredPamphlet.venue && <>
会場
{featuredPamphlet.venue.prefecture} {featuredPamphlet.venue.name}
} {featuredPamphlet.page_count > 1 && <>
ページ
{featuredPamphlet.page_count}ページ
}
スライドで読む リールで次々に見る
{featureCount > 1 && (
{currentFeatureIndex + 1} / {featureCount}
)} ); } function CatalogHomeApp() { const [enteredKeyword, setEnteredKeyword] = useState(''); const [searchConditions, setSearchConditions] = useState({ keyword: '', kind: '', category: '', prefecture: '' }); const [currentLocation, setCurrentLocation] = useState(null); const [isLocating, setIsLocating] = useState(false); const [displayMode, setDisplayMode] = useState('list'); const [listedPamphlets, setListedPamphlets] = useState([]); const [promotedPamphlets, setPromotedPamphlets] = useState([]); const [paginationMeta, setPaginationMeta] = useState(null); const [isLoadingPamphlets, setIsLoadingPamphlets] = useState(true); const [loadErrorMessage, setLoadErrorMessage] = useState(''); const [heroPamphlets, setHeroPamphlets] = useState([]); const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false); const activeFilterCount = [searchConditions.prefecture, searchConditions.category].filter(Boolean).length; const loadPamphlets = useCallback(async (pageNumber) => { setIsLoadingPamphlets(true); setLoadErrorMessage(''); try { // 開催地の無いチラシ・広告なども受け取れるので include_without_venue を付ける const searchResponse = await requestJson(`${searchEndpointUrl}?${buildQueryString({ include_without_venue: 1, ...searchConditions, latitude: currentLocation?.latitude, longitude: currentLocation?.longitude, page: pageNumber, })}`); setListedPamphlets((alreadyListedPamphlets) => ( pageNumber === 1 ? searchResponse.data : [...alreadyListedPamphlets, ...searchResponse.data] )); setHeroPamphlets((currentHeroPamphlets) => ( currentHeroPamphlets.length > 0 ? currentHeroPamphlets // 開催中のものを先に、あとは開催が近い順(一覧の並び)で最大 5 件 : [...searchResponse.data] .filter((pamphlet) => pamphlet.cover_image_url) .sort((pamphletA, pamphletB) => Number(pamphletB.is_ongoing) - Number(pamphletA.is_ongoing)) .slice(0, 5) )); if (pageNumber === 1) setPromotedPamphlets(searchResponse.promoted); setPaginationMeta(searchResponse.meta); } catch (caughtError) { setLoadErrorMessage('読み込みに失敗しました。時間をおいて再度お試しください。'); } finally { setIsLoadingPamphlets(false); } }, [searchConditions, currentLocation]); useEffect(() => { loadPamphlets(1); }, [loadPamphlets]); const handleSearchSubmit = useCallback((submitEvent) => { submitEvent.preventDefault(); setSearchConditions((currentConditions) => ({ ...currentConditions, keyword: enteredKeyword })); }, [enteredKeyword]); const handleLocateClick = useCallback(() => { if (currentLocation) { setCurrentLocation(null); return; } if (!navigator.geolocation) { window.alert('このブラウザでは位置情報を利用できません。'); return; } setIsLocating(true); navigator.geolocation.getCurrentPosition( (position) => { setCurrentLocation({ latitude: Number(position.coords.latitude.toFixed(5)), longitude: Number(position.coords.longitude.toFixed(5)), }); setIsLocating(false); document.getElementById('catalog-results')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, () => { window.alert('位置情報を取得できませんでした。ブラウザの設定で位置情報の利用を許可してください。'); setIsLocating(false); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }, ); }, [currentLocation]); const hasMorePages = paginationMeta && paginationMeta.current_page < paginationMeta.last_page; return ( <> {/* 検索: 表にはキーワードと1行の操作(種類・近い順・絞り込み)だけ。都道府県とカテゴリは「絞り込み」の中 */}
setEnteredKeyword(changeEvent.target.value)} aria-label="キーワード" />
{KIND_OPTIONS.map((kindOption) => ( ))}
{activeFilterCount > 0 && !isFilterPanelOpen && (
{searchConditions.prefecture && ( )} {searchConditions.category && ( )}
)} {isFilterPanelOpen && (
カテゴリ
{Object.entries(categoryLabelsByKey).map(([categoryKey, categoryLabel]) => ( ))}
)}
リール
{paginationMeta && displayMode === 'list' && ( {paginationMeta.total}件 )}
{displayMode === 'map' ? ( ) : ( <> {promotedPamphlets.length > 0 && (

近くのおすすめPICK UP

PR
{promotedPamphlets.map((promotedPamphlet) => ( ))}
)}

{currentLocation ? '近い順' : '開催が近い順'}PAMPHLETS

{loadErrorMessage &&

{loadErrorMessage}

} {!isLoadingPamphlets && listedPamphlets.length === 0 ? (

条件に合うパンフレット・ポスター・チラシはまだありません。

) : (
{listedPamphlets.map((listedPamphlet) => ( ))}
)}
{isLoadingPamphlets && 読み込み中…} {!isLoadingPamphlets && hasMorePages && ( )}
)}
); } ReactDOM.createRoot(rootElement).render(); })();