// --- 依存関係のフォールバック (プレビュー環境でのクラッシュ回避用) --- if (typeof window !== 'undefined') { if (!window.React) { window.React = { useState: () => [null, ()=>{}], useEffect: ()=>{}, useMemo: (f)=>f() }; } if (!window.Icons) { window.Icons = new Proxy({}, { get: () => function DummyIcon(props) { return window.React.createElement('span', {style:{display:'inline-block', width:'1em', height:'1em', backgroundColor:'#4b5563', borderRadius:'50%'}}, ''); } }); } if (!window.FadeInSection) { window.FadeInSection = function Fade({children, className}) { return window.React.createElement('div', {className}, children); }; } if (!window.DateSelectors) { window.DateSelectors = function DS({dateValue, onChange, label, className}) { return window.React.createElement('div', {className}, window.React.createElement('input', {type:'date', value:dateValue, onChange: e=>onChange(e.target.value)})); }; } if (!window.getCourseDisplay) window.getCourseDisplay = c => c === '30min' ? '30分' : (c === '60min' ? '60分' : c); if (!window.getCourseShortDisplay) window.getCourseShortDisplay = c => c === '30min' ? '30分' : (c === '60min' ? '60分' : c); if (!window.fetchApi) window.fetchApi = async () => ({ success: true }); } const { useState, useEffect, useMemo } = window.React; const Icons = window.Icons; const FadeInSection = window.FadeInSection; const DateSelectors = window.DateSelectors; const getCourseDisplay = window.getCourseDisplay; const getCourseShortDisplay = window.getCourseShortDisplay; const fetchApi = window.fetchApi; const NO_IMAGE_SRC = window.NO_IMAGE_SRC || ''; const DUMMY_QR = window.DUMMY_QR || ''; // -------------------------------------------------------------------------- const InfoModal = ({ type, onClose }) => { const handleContactSubmit = async (e) => { e.preventDefault(); const btn = e.target.querySelector('button[type="submit"]'); btn.disabled = true; btn.innerText = '送信中...'; try { const res = await fetchApi('send_contact', { method: 'POST', body: JSON.stringify({ name: e.target.sender_name.value, email: e.target.sender_email.value, message: e.target.sender_message.value, type: 'company' }) }); if (res.success) { alert("お問い合わせを送信しました。担当者よりご連絡いたします。"); onClose(); } else { alert("送信に失敗しました。時間をおいて再度お試しください。"); } } catch (err) { alert("通信エラーが発生しました。"); } finally { btn.disabled = false; btn.innerText = '送信する'; } }; const renderContent = () => { switch(type) { case 'tokusho': return (

特定商取引法に基づく表記

販売業者株式会社 神龍
運営統括責任者神龍 阿弥
所在地広島市
メールアドレス予約関連:support@amifortune.info
会社/採用:admin@amifortune.info
販売価格各鑑定師のプロフィールページに表示された価格(税込)
商品代金以外の必要料金インターネット接続料金等
お支払い方法paysysによる事前決済
代金の支払時期【前払い】鑑定予約時間の60分前まで
サービスの提供時期ご予約いただいた日時
キャンセルについて鑑定開始の24時間前まではキャンセル料無料。それ以降および無断キャンセルの場合は、鑑定料金の100%を申し受けます。
); case 'company': return (

会社概要

会社名株式会社 神龍
代表取締役神龍 阿弥
所在地広島市
事業内容占い鑑定業務、インターネット占いサイトの企画・制作・運営
お問い合わせadmin@amifortune.info
); case 'terms': return (

利用規約

この利用規約は,株式会社 神龍が提供するサービスの利用条件を定めるものです。

第1条(免責事項)

本サービスが提供する鑑定結果の完全性、正確性、確実性等について、いかなる保証も行いません。鑑定結果をどのように利用するかは、ユーザー自身の判断と責任によるものとします。

第2条(禁止事項)

法令または公序良俗に違反する行為、占い師に対する誹謗中傷やハラスメント行為を禁止します。

); case 'privacy': return (

プライバシーポリシー

株式会社 神龍は,本サービスにおける個人情報の取扱いについて,以下のとおり定めます。

第1条(個人情報の定義と収集)

氏名,生年月日,連絡先など特定の個人を識別できる情報に加え、鑑定に必要な「出生時間」「出生地」「相談内容」なども個人情報として厳重に管理いたします。

第2条(第三者提供の制限)

法令で認められる場合を除き,あらかじめユーザーの同意を得ることなく第三者に個人情報を提供することはありません。

); case 'cancel_policy': return (

キャンセルポリシー

1. キャンセルの期限と料金

2. 遅刻について

開始予定時刻から15分以上経過しても接続がない場合は、無断キャンセル扱いとさせていただきます。

); case 'contact': return (

お問い合わせ

会社に関するお問い合わせはこちらからお願いいたします。
宛先: admin@amifortune.info

); default: return
コンテンツがありません。
; } }; return (
Information
{renderContent()}
); }; const ReviewModal = ({ booking, onClose, onSuccess }) => { const [form, setForm] = useState({ customer_name: booking.user_name || '', age_group: '', rating: 5, testimonial_text: '' }); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); try { await window.fetchApi('submit_testimonial', { method: 'POST', body: JSON.stringify({ ...form, teller_name: `${booking.teller_name} 先生`, fortune_teller_id: booking.fortune_teller_id }) }); localStorage.setItem(`reviewed_${booking.id}`, 'true'); alert('レビューを送信しました!\n(掲載には管理者の確認が必要です)'); if(onSuccess) onSuccess(); onClose(); } catch (error) { alert('エラーが発生しました'); } finally { setIsSubmitting(false); } }; const inputClass = "w-full bg-black/40 border border-white/20 rounded-xl p-3.5 text-white focus:border-dawnGold focus:bg-black/60 outline-none transition-all placeholder-gray-500 hover:border-white/40"; return (

レビューを投稿

担当鑑定師

{booking.teller_name} 先生

setForm({...form, customer_name: e.target.value})} placeholder="ニックネーム" />
setForm({...form, age_group: e.target.value})} placeholder="例: 30代 女性" />
{[1, 2, 3, 4, 5].map(star => ( ))}
); }; const WaitlistModal = ({ teller, onClose }) => { const [form, setForm] = useState({ name: localStorage.getItem('user_name') || '', email: localStorage.getItem('user_email') || '' }); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); localStorage.setItem('user_name', form.name); localStorage.setItem('user_email', form.email); try { const res = await fetchApi('register_waitlist', { method: 'POST', body: JSON.stringify({ fortune_teller_id: teller.id, user_name: form.name, user_email: form.email }) }); if (res.success) { alert(`${teller.name} 先生のキャンセル待ちに登録しました。\n空きが出次第、メールにてお知らせいたします。`); onClose(); } else { alert(res.message || '登録に失敗しました。'); } } catch (error) { alert('エラーが発生しました。時間をおいてお試しください。'); } finally { setIsSubmitting(false); } }; const inputClass = "w-full bg-black/40 border border-white/20 rounded-xl p-3.5 text-white focus:border-dawnGold focus:bg-black/60 outline-none transition-all placeholder-gray-500 hover:border-white/40"; return (

空き通知を受け取る

対象の鑑定師

{teller.name} 先生

現在ご予約が満了しております。
ご登録いただくと、キャンセル等で空き枠が発生した際に、いち早くメールでお知らせいたします。

setForm({...form, name: e.target.value})} placeholder="山田 太郎" />
setForm({...form, email: e.target.value})} placeholder="example@mail.com" />
); }; const BookingModal = ({ teller, onClose }) => { const [step, setStep] = useState(1); const [form, setForm] = useState({ name: '', furigana: '', email: '', birth_date: '', gender: 'female', date: '', time: '', course: '30min', consultation_content: '' }); const [existingBookings, setExistingBookings] = useState([]); const [myExistingBookings, setMyExistingBookings] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [couponCode, setCouponCode] = useState(() => { if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search); const urlCoupon = params.get('coupon'); if (urlCoupon) { sessionStorage.setItem('saved_coupon', urlCoupon); return urlCoupon; } return sessionStorage.getItem('saved_coupon') || ''; } return ''; }); const [couponMessage, setCouponMessage] = useState(''); const [discount, setDiscount] = useState(0); const [finalPrice, setFinalPrice] = useState(0); const [isValidatingCoupon, setIsValidatingCoupon] = useState(false); const availableMethods = useMemo(() => teller.available_methods ? teller.available_methods.split(',') : [], [teller]); const dateList = useMemo(() => { const list = []; const today = new Date(); const weekDays = ['日', '月', '火', '水', '木', '金', '土']; for (let i = 0; i < 14; i++) { const d = new Date(today); d.setDate(today.getDate() + i); list.push({ value: d.toISOString().split('T')[0], display: `${d.getMonth() + 1}/${d.getDate()}`, week: weekDays[d.getDay()] }); } return list; }, []); const timeSlots = useMemo(() => { const slots = []; const [startH, startM] = (teller.work_start_time || '10:00').split(':').map(Number); const [endH, endM] = (teller.work_end_time || '20:00').split(':').map(Number); let current = startH * 60 + startM; const end = endH * 60 + endM; while (current < end) { slots.push(`${Math.floor(current/60).toString().padStart(2,'0')}:${(current%60).toString().padStart(2,'0')}`); current += 30; } return slots; }, [teller]); const endMinutes = useMemo(() => { const [endH, endM] = (teller.work_end_time || '20:00').split(':').map(Number); return endH * 60 + endM; }, [teller.work_end_time]); const getOccupiedSlots = (bookings, targetDateStr) => { const occupied = new Set(); bookings.forEach(b => { if (b.status === 'cancelled') return; const bDateStr = b.booking_date || ''; const [bDate, bTime] = bDateStr.split(' '); if (bDate === targetDateStr && bTime) { const timeStr = bTime.substring(0, 5); occupied.add(timeStr); if (b.course && b.course.includes('60')) { const [h, m] = timeStr.split(':').map(Number); const nextM = m + 30; const nextH = h + Math.floor(nextM / 60); const finalM = nextM % 60; const nextTimeStr = `${String(nextH).padStart(2, '0')}:${String(finalM).padStart(2, '0')}`; occupied.add(nextTimeStr); } } }); return occupied; }; const occupiedByTeller = useMemo(() => getOccupiedSlots(existingBookings, form.date), [existingBookings, form.date]); const occupiedByMe = useMemo(() => getOccupiedSlots(myExistingBookings, form.date), [myExistingBookings, form.date]); const base30 = parseInt(teller.price_30min) || 5000; const base60 = parseInt(teller.price_60min) || 9000; useEffect(() => { const basePrice = form.course === '30min' ? base30 : base60; setFinalPrice(Math.max(0, basePrice - discount)); }, [form.course, discount, base30, base60]); useEffect(() => { fetchApi('bookings_public').then(data => { if (Array.isArray(data)) setExistingBookings(data.filter(b => b.fortune_teller_id == teller.id)); }); setForm(prev => ({ ...prev, name: localStorage.getItem('user_name') || prev.name, furigana: localStorage.getItem('user_furigana') || prev.furigana, email: localStorage.getItem('user_email') || prev.email, birth_date: localStorage.getItem('user_birth_date') || prev.birth_date, gender: localStorage.getItem('user_gender') || prev.gender })); }, [teller.id]); useEffect(() => { if (step === 2 && form.email) { fetchApi('my_bookings', { method: 'POST', body: JSON.stringify({ email: form.email, birth_date: form.birth_date }) }).then(data => { if (Array.isArray(data)) setMyExistingBookings(data.filter(b => b.status !== 'cancelled')); }); } }, [step, form.email, form.birth_date]); const validateCoupon = async (codeToValidate = couponCode) => { if (!codeToValidate) return; setIsValidatingCoupon(true); setCouponMessage(''); try { const data = await fetchApi('validate_coupon', { method: 'POST', body: JSON.stringify({ code: codeToValidate }) }); if (data.valid) { const basePrice = form.course === '30min' ? base30 : base60; let discountVal = data.discount_type === 'percent' ? Math.floor(basePrice * (data.discount_value / 100)) : data.discount_value; setDiscount(discountVal); setCouponMessage(`適用中: ${data.description}`); } else { setDiscount(0); setCouponMessage('無効なクーポンコードです'); } } catch (e) { setCouponMessage('検証エラー'); } finally { setIsValidatingCoupon(false); } }; useEffect(() => { if (step === 2 && couponCode) { validateCoupon(couponCode); } }, [step, form.course]); const submitBooking = async () => { setIsSubmitting(true); localStorage.setItem('user_name', form.name); localStorage.setItem('user_furigana', form.furigana); localStorage.setItem('user_email', form.email); localStorage.setItem('user_birth_date', form.birth_date); localStorage.setItem('user_gender', form.gender); const bookingData = { ...form, user_name: form.name, user_email: form.email, fortune_teller_id: teller.id, booking_date: `${form.date} ${form.time}:00`, payment_method: 'pending', consultation_method: '', user_contact_info: '', coupon_code: discount > 0 ? couponCode : null, discount_amount: discount, final_price: finalPrice }; try { const res = await fetchApi('create_booking', { method: 'POST', body: JSON.stringify(bookingData) }); if (res.success) { setStep(4); sessionStorage.removeItem('saved_coupon'); } else { alert(res.message || "予約エラーが発生しました"); } } catch (e) { alert("予約エラーが発生しました"); } finally { setIsSubmitting(false); } }; const inputClass = "w-full bg-black/40 border border-white/20 rounded-xl p-3.5 text-white focus:border-dawnGold focus:bg-black/60 outline-none transition-all placeholder-gray-600 hover:border-white/40 focus:ring-1 focus:ring-dawnGold/50"; const labelClass = "block text-xs text-yellow-400 font-bold mb-2 tracking-widest uppercase text-shadow ml-1"; return (
予約手続き

{step === 4 ? '仮予約完了' : `${teller.name} 先生`}

{step < 4 && (
{[1, 2, 3].map(num => (
= num ? 'bg-gradient-to-br from-yellow-400 to-yellow-600 text-black shadow-[0_0_10px_rgba(250,204,21,0.5)]' : 'bg-gray-800 text-gray-500 border border-gray-700'}`}> {num}
))}
)}
{step === 1 && (
{teller.name}
{teller.role}
{availableMethods.map(m => ( {m} ))}
{ e.preventDefault(); setStep(2); }} className="space-y-8 pb-10 md:pb-0">

お客様情報 (必須)

setForm({...form,name:e.target.value})} placeholder="山田 太郎" />
setForm({...form, birth_date: d})} className="w-full" />
setForm({...form,email:e.target.value})} placeholder="example@mail.com" />
setForm({...form,furigana:e.target.value})} placeholder="ヤマダ タロウ" />
)} {step === 2 && (
{dateList.map(d => ( ))}
{form.date ? timeSlots.map(t => { const now = new Date(); const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; let isPastTime = false; if (form.date === todayStr) { const [h, m] = t.split(':').map(Number); if (new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m) < now) isPastTime = true; } const isOccupiedByMe = occupiedByMe.has(t); const isOccupiedByTeller = occupiedByTeller.has(t); const [h, m] = t.split(':').map(Number); const startMins = h * 60 + m; const duration = form.course === '60min' ? 60 : 30; const bookingEndMins = startMins + duration; const isOverTime = bookingEndMins > endMinutes; let isNextOccupied = false; if (form.course === '60min' && !isOverTime) { const nextM = m + 30; const nextH = h + Math.floor(nextM / 60); const finalM = nextM % 60; const nextTimeStr = `${String(nextH).padStart(2, '0')}:${String(finalM).padStart(2, '0')}`; if (occupiedByTeller.has(nextTimeStr) || occupiedByMe.has(nextTimeStr)) { isNextOccupied = true; } } const isDisabled = isPastTime || isOccupiedByMe || isOccupiedByTeller || isOverTime || isNextOccupied; return ( ); }) :
先に上の日付を選択してください
}
setCouponCode(e.target.value)} placeholder="お持ちの場合は入力" />
{couponMessage &&

0 ? 'text-green-400' : 'text-red-400'}`}>{discount > 0 ? '✓ ' : '⚠ '}{couponMessage}

}
)} {step === 3 && (
以下の内容で仮予約を確定しますか?
担当鑑定師 {teller.name} 先生
予約日時 {form.date.replace(/-/g, '/')} {form.time}
コース {getCourseDisplay(form.course)}
お名前 {form.name} 様
お支払い予定額
{discount > 0 && ¥{(form.course === '30min' ? base30 : base60).toLocaleString()}} ¥{finalPrice.toLocaleString()}
※予約確定後、マイページより対話方法を選択し、事前決済(paysys等)を完了していただく必要があります。
※決済完了をもって正式な予約確定となります。
)} {step === 4 && (

仮予約を受付ました

確認メールをご登録のアドレスに送信いたしました。
予約はまだ完了していません。

続いて「マイページ」より当日の対話方法を選択し、
お支払いを完了させてご予約を確定させてください。
※鑑定開始の60分前までにお支払いがない場合、キャンセルとなる場合がございます。

)}
); }; const CheckReservationModal = ({ onClose, onRebook }) => { const [email, setEmail] = useState(localStorage.getItem('user_email') || ''); const [birthDate, setBirthDate] = useState(localStorage.getItem('user_birth_date') || ''); const [myBookings, setMyBookings] = useState(null); const [loading, setLoading] = useState(false); const [payingBooking, setPayingBooking] = useState(null); const [payingExtensionBooking, setPayingExtensionBooking] = useState(null); const [selectedMethod, setSelectedMethod] = useState(''); const [contactInfo, setContactInfo] = useState(''); const [isPaymentProcessing, setIsPaymentProcessing] = useState(false); const [activeTab, setActiveTab] = useState('upcoming'); const [activeSessionBooking, setActiveSessionBooking] = useState(null); const [showExtensionQR, setShowExtensionQR] = useState(false); const [reviewingBooking, setReviewingBooking] = useState(null); const fetchMyBookings = async () => { if (!email || !birthDate) return; setLoading(true); localStorage.setItem('user_email', email); localStorage.setItem('user_birth_date', birthDate); try { const data = await fetchApi('my_bookings', { method: 'POST', body: JSON.stringify({ email, birth_date: birthDate }) }); setMyBookings(Array.isArray(data) ? data : []); } catch(e) { setMyBookings([]); } finally { setLoading(false); } }; useEffect(() => { if (!myBookings) { setActiveSessionBooking(null); return; } const checkActive = () => { const now = new Date(); let active = null; for (const b of myBookings) { if (b.status === 'confirmed' || b.status === 'paid') { const dateStr = b.booking_date || ''; const start = new Date(dateStr.replace(/-/g, '/')); const duration = (b.course && b.course.includes('60')) ? 60 : 30; const end = new Date(start.getTime() + duration * 60000); const displayStart = new Date(start.getTime() - 10 * 60000); if (now >= displayStart && now <= end) { active = { ...b, isBeforeStart: now < start }; break; } } } setActiveSessionBooking(active); }; checkActive(); const timer = setInterval(checkActive, 10000); return () => clearInterval(timer); }, [myBookings]); const handleConfirmBooking = async () => { setIsPaymentProcessing(true); try { await fetchApi('booking_status', { method: 'POST', body: JSON.stringify({ id: payingBooking.id, status: 'confirmed', consultation_method: selectedMethod, user_contact_info: contactInfo }) }); alert('対話方法を選択し、予約が確定しました。\n登録されたメールアドレスにpaysysのお支払いリンクを送信しました。'); setPayingBooking(null); fetchMyBookings(); } catch(e) { alert('エラーが発生しました'); } finally { setIsPaymentProcessing(false); } }; const handleNotifyPaymentComplete = async (bookingId) => { setIsPaymentProcessing(true); try { await fetchApi('booking_status', { method: 'POST', body: JSON.stringify({ id: bookingId, status: 'paid' }) }); alert('お支払い完了の通知を送信しました!\n確認が取れ次第、鑑定にお進みいただけます。'); fetchMyBookings(); } catch(e) { alert('エラーが発生しました'); } finally { setIsPaymentProcessing(false); } }; const handleCancel = async (bookingId) => { if (!confirm('本当に予約をキャンセルしますか?\n※キャンセルすると元に戻せません。')) return; try { await fetchApi('booking_status', { method: 'POST', body: JSON.stringify({ id: bookingId, status: 'cancelled' }) }); alert('予約をキャンセルしました。'); fetchMyBookings(); } catch(e) { alert('エラーが発生しました'); } }; const { upcoming, past } = useMemo(() => { if (!myBookings) return { upcoming: [], past: [] }; const now = new Date(); const u = [], p = []; myBookings.forEach(b => { if (b.status === 'cancelled') { p.push(b); return; } const dateStr = b.booking_date || ''; const start = new Date(dateStr.replace(/-/g, '/')); const end = new Date(start.getTime() + ((b.course && b.course.includes('60')) ? 60 : 30) * 60000); if (b.status === 'completed' || end < now) p.push(b); else u.push(b); }); u.sort((a, b) => new Date((a.booking_date || '').replace(/-/g, '/')) - new Date((b.booking_date || '').replace(/-/g, '/'))); p.sort((a, b) => new Date((b.booking_date || '').replace(/-/g, '/')) - new Date((a.booking_date || '').replace(/-/g, '/'))); return { upcoming: u, past: p }; }, [myBookings]); const getStatusBadge = (status) => { switch(status) { case 'confirmed': return 支払い待ち; case 'paid': return 確定済 (支払完了); case 'pending': return 仮予約; case 'completed': return 完了; case 'cancelled': return キャンセル; default: return {status}; } }; const inputClass = "w-full bg-black/40 border border-white/20 rounded-xl p-3.5 text-white focus:border-dawnGold focus:bg-black/60 outline-none transition-all placeholder-gray-500 hover:border-white/40"; const currentList = activeTab === 'upcoming' ? upcoming : past; return (
マイページ

{payingExtensionBooking ? '延長料金のお支払い' : payingBooking ? '対話方法の選択' : '予約確認・履歴'}

{payingExtensionBooking ? (

追加オプション

10分延長

ご請求額¥1,500

※鑑定士からの延長許可が下りました

paysysで ¥1,500 を支払う
) : payingBooking ? (
当日の対話方法を選択してください。確定後、ご指定のメールアドレスに「接続先情報」と「決済URL」をお送りします。

対話方法を選択 *

{(payingBooking.available_methods || '電話,Zoom').split(',').map(m => ())}
{selectedMethod && (
setContactInfo(e.target.value)} placeholder={`${selectedMethod}の連絡先を入力してください`} />
)}
) : !myBookings ? (
setEmail(e.target.value)} className={inputClass} placeholder="example@mail.com" />
) : (
{activeSessionBooking && activeTab === 'upcoming' && (
{activeSessionBooking.isBeforeStart ? '🟢 まもなく鑑定開始 (入室可能)' : '🟢 現在鑑定中'}
{activeSessionBooking.teller_name} 先生
{!showExtensionQR ? ( ) : (
paysysで延長料金を支払う
)}
)}
{currentList.length === 0 ? (

{activeTab === 'upcoming' ? 'これからの予約はありません' : '過去の履歴はありません'}

) : (
{currentList.map((b, index) => { const isReviewed = localStorage.getItem(`reviewed_${b.id}`); return ( {b.status === 'confirmed' &&
} {b.status === 'paid' &&
}
{b.teller_name}

{(b.booking_date || '').split(' ')[0]}

{(b.booking_date || '').split(' ')[1]?.substring(0,5)} 〜

{getStatusBadge(b.status)}

{b.teller_name} 先生

{getCourseDisplay(b.course)}¥{b.final_price ? Number(b.final_price).toLocaleString() : '-'}
{b.status === 'confirmed' && activeTab === 'upcoming' && (

以下のボタンよりお支払いください

paysysでお支払い
)} {b.status === 'completed' && activeTab === 'past' && !isReviewed && ( )} {b.status === 'completed' && activeTab === 'past' && isReviewed && (
レビュー送信済
)}
{b.status === 'pending' && activeTab === 'upcoming' && } {(b.status === 'pending' || b.status === 'confirmed') && activeTab === 'upcoming' && } {(activeTab === 'past' || b.status === 'cancelled') && }
) })}
)}
)}
{reviewingBooking && ( setReviewingBooking(null)} onSuccess={fetchMyBookings} /> )}
); }; const TellerProfileModal = ({ teller, onClose, openBooking, openWaitlist }) => { const [reviews, setReviews] = useState([]); const [loadingReviews, setLoadingReviews] = useState(true); useEffect(() => { const fetchReviews = async () => { try { const revData = await window.fetchApi('testimonials_public', { urlParams: `teller_id=${teller.id}` }); setReviews(revData || []); } catch (e) { console.error(e); } finally { setLoadingReviews(false); } }; fetchReviews(); }, [teller.id]); const isAvailable = parseInt(teller.is_available) === 1; return (
{/* ヘッダー */}
Profile
{/* プロフ画像 */}
{teller.name}
{isAvailable ? '予約可' : '満了'}
{/* プロフ詳細 */}
{teller.role}

{teller.name}

{/* 星評価 */}
{Number(teller.avg_rating || 0).toFixed(1)} ({teller.review_count || 0}件の評価)

{teller.description}

{/* お客様の声 (レビュー) セクション */}

この先生への口コミ・評価

{loadingReviews ? (
) : reviews.length === 0 ? (

まだ口コミは投稿されていません。

) : (
{reviews.map(r => (
{[...Array(r.rating || 5)].map((_, i) => )}

「{r.testimonial_text}」

{r.customer_name} 様 {r.age_group} {r.created_at ? r.created_at.split(' ')[0].replace(/-/g, '/') : ''}
))}
)}
); }; // --- ファイルの一番下に追加 --- window.InfoModal = InfoModal; window.ReviewModal = ReviewModal; window.WaitlistModal = WaitlistModal; window.BookingModal = BookingModal; window.CheckReservationModal = CheckReservationModal; window.TellerProfileModal = TellerProfileModal;