/** * リール: スマホ向けの全画面・縦スワイプのパンフレット閲覧。 * * - 上下スワイプで次 / 前のパンフレット(CSS の scroll-snap で 1 件ずつ止める) * - 左右スワイプでそのパンフレットのページ送り(同じく scroll-snap) * - 「近く」を押すと現在地から近い順になり、その地点を配信範囲に含む優先枠(PR)を先頭に混ぜる * - 一覧は公開 API(/api/v1/pamphlets)をページ単位で読み足す。ページ画像は画面に近づいた分だけ詳細 API で取る * - 1.5 秒以上表示されたパンフレットを 1 回だけ閲覧として記録する */ (function renderPamphletReels() { 'use strict'; const { useState, useEffect, useCallback, useRef } = React; const rootElement = document.getElementById('pamphlet-reels-root'); const searchEndpointUrl = rootElement.dataset.searchEndpoint; const detailEndpointTemplate = rootElement.dataset.detailEndpointTemplate; const viewEndpointTemplate = rootElement.dataset.viewEndpointTemplate; const listUrl = rootElement.dataset.listUrl; const categoryLabelsByKey = JSON.parse(rootElement.dataset.categories); // お気に入り。ログインしていなければ、押したときにログイン画面へ(ログイン後にリールへ戻る) const isSignedIn = rootElement.dataset.isSignedIn === '1'; const favoritePamphletIdSet = new Set(JSON.parse(rootElement.dataset.favoritePamphletIds || '[]')); const favoriteEndpointTemplate = rootElement.dataset.favoriteEndpointTemplate; const loginUrl = rootElement.dataset.loginUrl; const PAMPHLETS_PER_REQUEST = 12; /** 残りがこの件数になったら次のページを読み足す */ const PREFETCH_REMAINING_COUNT = 3; /** 表示中の前後この件数までページ画像を読んでおく */ const DETAIL_PRELOAD_RANGE = 1; const VIEW_RECORD_DELAY_MS = 1500; const SWIPE_HINT_STORAGE_KEY = 'pamphletReelsSwipeHintSeen'; 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); } const ICON_PATHS = { book: 'M4 5.5C6.5 4.5 9.5 4.5 12 6c2.5-1.5 5.5-1.5 8-.5v13c-2.5-1-5.5-1-8 .5-2.5-1.5-5.5-1.5-8-.5z M12 6v13.5', pin: 'M12 21s-6.5-6.2-6.5-11a6.5 6.5 0 0 1 13 0c0 4.8-6.5 11-6.5 11z M12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z', share: 'M12 3v12 M7.5 7.5 12 3l4.5 4.5 M5 12v7.5h14V12', list: 'M4 6h16 M4 12h16 M4 18h16', near: 'M12 2v3 M12 19v3 M2 12h3 M19 12h3 M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10z', heart: 'M12 20.5s-7.5-4.6-7.5-10.2A4.3 4.3 0 0 1 12 7.6a4.3 4.3 0 0 1 7.5 2.7c0 5.6-7.5 10.2-7.5 10.2z', calendar: 'M5 5h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z M3 10h18 M8 3v4 M16 3v4 M12 13v5 M9.5 15.5h5', }; function ReelIcon({ name, isFilled = false }) { return ( ); } function buildSearchUrl(searchConditions, pageNumber) { // 開催地の無いチラシ・広告なども受け取れるので include_without_venue を付ける const queryParameters = new URLSearchParams({ page: String(pageNumber), per_page: String(PAMPHLETS_PER_REQUEST), include_without_venue: '1' }); Object.entries(searchConditions).forEach(([parameterName, parameterValue]) => { if (parameterValue !== null && parameterValue !== undefined && parameterValue !== '') { queryParameters.set(parameterName, String(parameterValue)); } }); return `${searchEndpointUrl}?${queryParameters}`; } /** 1 件分。横方向のページ送りと、下部の情報・右側の操作ボタン */ /** * ページのタップ: 1 回でバーやボタンを消してパンフレットだけを見せる(もう 1 回で戻す)。 * 2 回(ダブルタップ)で、拡大できる閲覧ページを開く。 */ function usePageTapHandler(onSingleTap, onDoubleTap) { const pendingTapTimerRef = useRef(null); return useCallback(() => { if (pendingTapTimerRef.current) { window.clearTimeout(pendingTapTimerRef.current); pendingTapTimerRef.current = null; onDoubleTap(); return; } pendingTapTimerRef.current = window.setTimeout(() => { pendingTapTimerRef.current = null; onSingleTap(); }, 260); }, [onSingleTap, onDoubleTap]); } function ReelItem({ reelEntry, pamphletDetail, itemIndex, onBecameVisible, onToggleChrome }) { const itemElementRef = useRef(null); const pageTrackRef = useRef(null); const [currentPageIndex, setCurrentPageIndex] = useState(0); const { pamphlet, isPromoted } = reelEntry; // 縦スクロールで画面の大半に入ったら「いま表示中」と親に知らせる useEffect(() => { const visibilityObserver = new IntersectionObserver(([observedEntry]) => { if (observedEntry.isIntersecting) onBecameVisible(itemIndex); }, { threshold: 0.6 }); visibilityObserver.observe(itemElementRef.current); return () => visibilityObserver.disconnect(); }, [itemIndex, onBecameVisible]); const handlePageTrackScroll = useCallback(() => { const pageTrack = pageTrackRef.current; setCurrentPageIndex(Math.round(pageTrack.scrollLeft / pageTrack.clientWidth)); }, []); const scrollToPage = useCallback((targetPageIndex) => { const pageTrack = pageTrackRef.current; pageTrack.scrollTo({ left: targetPageIndex * pageTrack.clientWidth, behavior: 'smooth' }); }, []); const [isFavorite, setIsFavorite] = useState(() => favoritePamphletIdSet.has(pamphlet.id)); const handleFavoriteClick = useCallback(async () => { if (!isSignedIn) { window.location.href = loginUrl; return; } const willBeFavorite = !isFavorite; setIsFavorite(willBeFavorite); try { await requestJson(favoriteEndpointTemplate.replace('__ID__', String(pamphlet.id)), { method: willBeFavorite ? 'PUT' : 'DELETE' }); if (willBeFavorite) favoritePamphletIdSet.add(pamphlet.id); else favoritePamphletIdSet.delete(pamphlet.id); } catch (favoriteError) { setIsFavorite(!willBeFavorite); window.alert(favoriteError.message || 'お気に入りを変更できませんでした。'); } }, [isFavorite, pamphlet.id]); const handleShareClick = useCallback(async () => { const shareData = { title: pamphlet.title, url: pamphlet.viewer_url }; if (navigator.share) { navigator.share(shareData).catch(() => {}); } else if (navigator.clipboard) { await navigator.clipboard.writeText(pamphlet.viewer_url); window.alert('リンクをコピーしました。'); } }, [pamphlet]); // 詳細が届くまでは表紙 1 枚だけで表示しておく const pages = pamphletDetail ? pamphletDetail.pages : [{ number: 1, image_url: pamphlet.cover_image_url, width: 3, height: 4 }]; const eventPeriod = formatEventPeriod(pamphlet.starts_on, pamphlet.ends_on); const totalPageCount = pamphlet.page_count || pages.length; const handlePageTap = usePageTapHandler(onToggleChrome, () => { window.location.href = pamphlet.viewer_url; }); return (
{pamphlet.cover_image_url && ( {/* 説明は2行だけの小さなパネル(パンフレットを大きく見せるため) */}
{pamphlet.is_ongoing && 開催中} {isPromoted && PR} {pamphlet.kind === 'poster' && ポスター} {pamphlet.category_label}

{pamphlet.title}

{[eventPeriod, pamphlet.venue ? `${pamphlet.venue.prefecture} ${pamphlet.venue.name}` : null, pamphlet.distance_km !== undefined ? `${pamphlet.distance_km}km` : null] .filter(Boolean).join('・')}

); } function PamphletReelsApp() { const [currentLocation, setCurrentLocation] = useState(null); const [isLocating, setIsLocating] = useState(false); const [selectedCategory, setSelectedCategory] = useState(''); const [selectedKind, setSelectedKind] = useState(''); const [reelEntries, setReelEntries] = useState([]); const [pamphletDetailsById, setPamphletDetailsById] = useState({}); const [activeItemIndex, setActiveItemIndex] = useState(0); const [isLoadingMore, setIsLoadingMore] = useState(false); const [hasReachedEnd, setHasReachedEnd] = useState(false); const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false); const [isChromeHidden, setIsChromeHidden] = useState(false); const handleToggleChrome = useCallback(() => { setIsFilterPanelOpen(false); setIsChromeHidden((isHidden) => !isHidden); }, []); const activeFilterCount = [selectedKind, selectedCategory].filter(Boolean).length; const kindLabels = { pamphlet: 'パンフレット', poster: 'ポスター' }; const activeFilterLabel = activeFilterCount === 0 ? '絞り込み' : [kindLabels[selectedKind], categoryLabelsByKey[selectedCategory]].filter(Boolean).join('・'); const [isSwipeHintVisible, setIsSwipeHintVisible] = useState(() => !window.localStorage.getItem(SWIPE_HINT_STORAGE_KEY)); const feedElementRef = useRef(null); const nextPageNumberRef = useRef(1); const requestedDetailIdsRef = useRef(new Set()); const recordedViewIdsRef = useRef(new Set()); const searchGenerationRef = useRef(0); const searchConditions = { kind: selectedKind, category: selectedCategory, latitude: currentLocation?.latitude, longitude: currentLocation?.longitude, }; const loadNextPage = useCallback(async (isFreshSearch) => { const searchGeneration = searchGenerationRef.current; const pageNumber = isFreshSearch ? 1 : nextPageNumberRef.current; setIsLoadingMore(true); try { const searchResponse = await requestJson(buildSearchUrl({ kind: selectedKind, category: selectedCategory, latitude: currentLocation?.latitude, longitude: currentLocation?.longitude, }, pageNumber)); // 条件を変えた後に古い検索の結果が届いても混ぜない if (searchGeneration !== searchGenerationRef.current) return; const newEntries = [ ...(pageNumber === 1 ? searchResponse.promoted.map((pamphlet) => ({ pamphlet, isPromoted: true })) : []), ...searchResponse.data.map((pamphlet) => ({ pamphlet, isPromoted: false })), ]; setReelEntries((loadedEntries) => { const baseEntries = isFreshSearch ? [] : loadedEntries; // PR と通常枠で同じパンフレットが二重に流れないようにする const loadedIds = new Set(baseEntries.map((entry) => entry.pamphlet.id)); return [...baseEntries, ...newEntries.filter((entry) => { if (loadedIds.has(entry.pamphlet.id)) return false; loadedIds.add(entry.pamphlet.id); return true; })]; }); nextPageNumberRef.current = pageNumber + 1; setHasReachedEnd(searchResponse.meta.current_page >= searchResponse.meta.last_page); } catch (caughtError) { setHasReachedEnd(true); } finally { if (searchGeneration === searchGenerationRef.current) setIsLoadingMore(false); } }, [selectedKind, selectedCategory, currentLocation]); // 条件が変わったら最初から読み直し、先頭に戻す useEffect(() => { searchGenerationRef.current += 1; setReelEntries([]); setActiveItemIndex(0); setHasReachedEnd(false); if (feedElementRef.current) feedElementRef.current.scrollTo({ top: 0 }); loadNextPage(true); }, [selectedKind, selectedCategory, currentLocation]); // 終わりが近づいたら読み足す useEffect(() => { const remainingCount = reelEntries.length - 1 - activeItemIndex; if (reelEntries.length > 0 && remainingCount < PREFETCH_REMAINING_COUNT && !isLoadingMore && !hasReachedEnd) { loadNextPage(false); } }, [activeItemIndex, reelEntries.length, isLoadingMore, hasReachedEnd, loadNextPage]); // 表示中と前後のパンフレットのページ画像(詳細)を読む useEffect(() => { reelEntries .slice(Math.max(0, activeItemIndex - DETAIL_PRELOAD_RANGE), activeItemIndex + DETAIL_PRELOAD_RANGE + 1) .forEach(({ pamphlet }) => { if (requestedDetailIdsRef.current.has(pamphlet.id)) return; requestedDetailIdsRef.current.add(pamphlet.id); requestJson(detailEndpointTemplate.replace('__ID__', pamphlet.id)) .then((detailResponse) => { setPamphletDetailsById((loadedDetails) => ({ ...loadedDetails, [pamphlet.id]: detailResponse.data })); }) .catch(() => requestedDetailIdsRef.current.delete(pamphlet.id)); }); }, [activeItemIndex, reelEntries]); // しばらく表示されたものを閲覧として記録する(スワイプで流し見しただけでは数えない) useEffect(() => { const activeEntry = reelEntries[activeItemIndex]; if (!activeEntry || recordedViewIdsRef.current.has(activeEntry.pamphlet.id)) return undefined; const viewRecordTimer = window.setTimeout(() => { recordedViewIdsRef.current.add(activeEntry.pamphlet.id); requestJson(viewEndpointTemplate.replace('__ID__', activeEntry.pamphlet.id), { method: 'POST' }).catch(() => {}); }, VIEW_RECORD_DELAY_MS); return () => window.clearTimeout(viewRecordTimer); }, [activeItemIndex, reelEntries]); useEffect(() => { if (activeItemIndex > 0 && isSwipeHintVisible) { setIsSwipeHintVisible(false); window.localStorage.setItem(SWIPE_HINT_STORAGE_KEY, '1'); } }, [activeItemIndex, isSwipeHintVisible]); // PC でも ↑ ↓ で 1 件ずつ送れるように useEffect(() => { const handleKeyDown = (keyboardEvent) => { const feedElement = feedElementRef.current; if (keyboardEvent.key === 'ArrowDown') feedElement.scrollBy({ top: feedElement.clientHeight, behavior: 'smooth' }); if (keyboardEvent.key === 'ArrowUp') feedElement.scrollBy({ top: -feedElement.clientHeight, behavior: 'smooth' }); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); const handleItemBecameVisible = useCallback((visibleItemIndex) => setActiveItemIndex(visibleItemIndex), []); 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); }, () => { window.alert('位置情報を取得できませんでした。設定で位置情報の利用を許可してください。'); setIsLocating(false); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }, ); }, [currentLocation]); return (
Pamfly
{isFilterPanelOpen && (
{[['', 'すべて'], ['pamphlet', 'パンフレット'], ['poster', 'ポスター']].map(([kindKey, kindLabel]) => ( ))}
{Object.entries(categoryLabelsByKey).map(([categoryKey, categoryLabel]) => ( ))}
)}
{reelEntries.map((reelEntry, itemIndex) => ( ))} {!isLoadingMore && reelEntries.length === 0 && (

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

)} {hasReachedEnd && reelEntries.length > 0 && (

すべて見終わりました

キーワードや地域で探すこともできます。

一覧で探す
)}
{isSwipeHintVisible && reelEntries.length > 1 && ( )}
); } ReactDOM.createRoot(rootElement).render(); })();