/* global React, ReactDOM */
const { useState, useEffect, useMemo, useRef } = React;
// ================== ORIGIN DATA ==================
const ORIGINS = [
{
id: 'br', code: 'BR',
name: { jp: 'ブラジル', en: 'Brazil' },
region: { jp: 'Campo das Vertentes · Minas Gerais', en: 'Campo das Vertentes · Minas Gerais' },
importer: 'Mirai Seeds Inc.',
importerSub: { jp: 'グアリロバ農園 オフィシャルパートナー', en: 'Official partner — Fazenda Guariroba' },
varieties: 'Yellow Topázio · Catuaí · Mundo Novo',
process: { jp: 'Natural / Honey / 嫌気性', en: 'Natural / Honey / Anaerobic' },
notes: { jp: 'チョコレート、キャラメル、トロピカルフルーツ', en: 'Chocolate, caramel, tropical fruit' },
pos: { x: 30.5, y: 64 }, // map %
flag: 'br'
},
{
id: 'pa', code: 'PA',
name: { jp: 'パナマ', en: 'Panama' },
region: { jp: 'Boquete · Volcán · Chiriquí', en: 'Boquete · Volcán · Chiriquí' },
importer: 'Brisa and Tierra',
importerSub: { jp: 'パナマ専門商社', en: 'Panama-focused importer' },
varieties: 'Geisha · Caturra · Typica',
process: { jp: 'Washed / Natural / Anaerobic', en: 'Washed / Natural / Anaerobic' },
notes: { jp: 'ジャスミン、ベルガモット、ピーチ', en: 'Jasmine, bergamot, peach' },
pos: { x: 24.5, y: 52 },
flag: 'pa'
},
{
id: 'tw', code: 'TW',
name: { jp: '台湾', en: 'Taiwan' },
region: { jp: 'Yunlin · Nantou · Alishan', en: 'Yunlin · Nantou · Alishan' },
importer: 'ORIOWL Co., Ltd.',
importerSub: { jp: '台湾珈琲専門商社', en: 'Taiwan coffee specialist' },
varieties: 'Typica · SL34 · Geisha',
process: { jp: 'Natural / Honey / Wine', en: 'Natural / Honey / Wine' },
notes: { jp: '紅茶、ライチ、和柑橘', en: 'Black tea, lychee, citrus' },
pos: { x: 78.5, y: 48 },
flag: 'tw'
},
{
id: 'cr', code: 'CR',
name: { jp: 'コスタリカ', en: 'Costa Rica' },
region: { jp: 'Tarrazú · West Valley · Naranjo', en: 'Tarrazú · West Valley · Naranjo' },
importer: 'PuraVida',
importerSub: { jp: 'コスタリカ専門商社', en: 'Costa Rica specialist' },
varieties: 'Caturra · Catuaí · Villa Sarchi',
process: { jp: 'White / Yellow / Black Honey', en: 'White / Yellow / Black Honey' },
notes: { jp: 'シトラス、シュガーケーン、フローラル', en: 'Citrus, sugarcane, floral' },
pos: { x: 25, y: 50.5 },
flag: 'cr'
},
{
id: 'id', code: 'ID',
name: { jp: 'インドネシア', en: 'Indonesia' },
region: { jp: 'Sumatra · Sulawesi · Java', en: 'Sumatra · Sulawesi · Java' },
importer: 'Rational Idea Inc.',
importerSub: { jp: 'Asosiasi Kopi Indonesia 日本総代理店', en: 'Japan agent for Asosiasi Kopi Indonesia' },
varieties: 'Tim Tim · Lini-S · Sigararutang',
process: { jp: 'Wet Hulled / Natural / Honey', en: 'Wet Hulled / Natural / Honey' },
notes: { jp: 'スパイス、ハーブ、ダークチョコ', en: 'Spice, herbs, dark chocolate' },
pos: { x: 80, y: 64 },
flag: 'id'
}
];
// ================== FLAG SVGs ==================
const Flag = ({ code }) => {
switch(code){
case 'br': return (
);
case 'pa': return (
);
case 'tw': return (
);
case 'cr': return (
);
case 'id': return (
);
default: return null;
}
};
// ================== ORIGIN MAP APP ==================
const OriginApp = () => {
const lang = useLang();
const [activeId, setActiveId] = useState('br');
const active = ORIGINS.find(o => o.id === activeId) || ORIGINS[0];
const idx = ORIGINS.findIndex(o => o.id === activeId);
const next = () => setActiveId(ORIGINS[(idx+1) % ORIGINS.length].id);
const prev = () => setActiveId(ORIGINS[(idx-1+ORIGINS.length) % ORIGINS.length].id);
return (
<>
{/* simplified continents */}
{/* North America */}
{/* South America */}
{/* Europe */}
{/* Africa */}
{/* Asia */}
{/* SE Asia / Indonesia */}
{/* Australia */}
{/* Pins */}
{ORIGINS.map(o => (
setActiveId(o.id)}>
{o.code}
))}
{lang==='jp'?'産地ネットワーク':'Origin Network'}
N · 東 · S · 西
5 origins · growing
{active.code}
{active.name[lang]}
{active.region[lang]}
N° 0{idx+1}
{lang==='jp'?'参加インポーター':'Member Importer'}
{active.importer}
{active.importerSub[lang]}
Varieties {active.varieties}
Process {active.process[lang]}
{lang==='jp'?'カップノート':'Cup Notes'} "{active.notes[lang]}"
← {lang==='jp'?'前の産地':'Prev'}
{idx+1} / {ORIGINS.length}
{lang==='jp'?'次の産地':'Next'} →
>
);
};
const OriginTabs = () => {
const lang = useLang();
const [activeId, setActiveId] = useState('br');
// sync with map by listening to clicks on .pin (kept simple — internal)
return (
<>
{ORIGINS.map(o => (
{
setActiveId(o.id);
// also update map
const ev = new CustomEvent('origin-jump', { detail: o.id });
window.dispatchEvent(ev);
}}>
N° 0{ORIGINS.indexOf(o)+1}
{o.name[lang]} · {o.code}
{o.importer}
))}
>
);
};
// ================== Lang hook ==================
function useLang(){
const [lang, setLang] = useState(document.body.dataset.lang || 'jp');
useEffect(() => {
const obs = new MutationObserver(() => {
setLang(document.body.dataset.lang || 'jp');
});
obs.observe(document.body, { attributes: true, attributeFilter: ['data-lang'] });
return () => obs.disconnect();
}, []);
return lang;
}
// ================== Combined Origins (single state, two views) ==================
const OriginsModule = () => {
const lang = useLang();
const [activeId, setActiveId] = useState('br');
const active = ORIGINS.find(o => o.id === activeId) || ORIGINS[0];
const idx = ORIGINS.findIndex(o => o.id === activeId);
const next = () => setActiveId(ORIGINS[(idx+1) % ORIGINS.length].id);
const prev = () => setActiveId(ORIGINS[(idx-1+ORIGINS.length) % ORIGINS.length].id);
return (
<>
{ORIGINS.map(o => (
setActiveId(o.id)}>
{o.code}
))}
{lang==='jp'?'産地ネットワーク':'Origin Network'}
{lang==='jp'?'5 産地 · 拡大中':'5 origins · growing'}
click any pin
{active.code}
{active.name[lang]}
{active.region[lang]}
N° 0{idx+1}
{lang==='jp'?'参加インポーター':'Member Importer'}
{active.importer}
{active.importerSub[lang]}
Varieties {active.varieties}
Process {active.process[lang]}
{lang==='jp'?'カップノート':'Cup Notes'}
"{active.notes[lang]}"
← {lang==='jp'?'前へ':'Prev'}
{idx+1} / {ORIGINS.length}
{lang==='jp'?'次へ':'Next'} →
{ORIGINS.map((o, i) => (
setActiveId(o.id)}>
N° 0{i+1}
{o.name[lang]} · {o.code}
{o.importer}
))}
>
);
};
// ================== SAMPLE FORM (multi-step) ==================
const SampleForm = () => {
const lang = useLang();
const [step, setStep] = useState(0);
const [data, setData] = useState({
company: '', name: '', email: '', phone: '',
role: '',
origins: [],
quantity: 'small',
process: [],
purpose: '',
timing: 'asap',
notes: ''
});
const [done, setDone] = useState(false);
const update = (k, v) => setData(d => ({ ...d, [k]: v }));
const toggleArr = (k, v) => setData(d => {
const arr = d[k];
return { ...d, [k]: arr.includes(v) ? arr.filter(x => x !== v) : [...arr, v] };
});
const t = (jp, en) => lang === 'jp' ? jp : en;
const steps = [
{ key: 'origins', label: t('産地選択', 'Origins') },
{ key: 'detail', label: t('詳細', 'Details') },
{ key: 'contact', label: t('連絡先', 'Contact') }
];
const canNext = () => {
if(step === 0) return data.origins.length > 0;
if(step === 1) return data.purpose.length > 0;
if(step === 2) return data.company && data.name && data.email;
return true;
};
const submit = () => {
// Submit to Contact Form 7 if linkoneCF7 (window.linkoneCF7) is configured
if (window.linkoneCF7 && window.linkoneCF7.sampleFormId && window.linkoneCF7.restUrl) {
const form = new FormData();
form.append('your-company', data.company);
form.append('your-name', data.name);
form.append('your-email', data.email);
form.append('your-phone', data.phone || '');
form.append('your-role', data.role || '');
form.append('your-origins', data.origins.map(id => {
const o = ORIGINS.find(x => x.id === id);
return o ? o.name.en : id;
}).join(', '));
form.append('your-process', data.process.join(', '));
form.append('your-quantity', data.quantity);
form.append('your-purpose', data.purpose);
form.append('your-timing', data.timing);
form.append('your-message', data.notes || '');
const url = window.linkoneCF7.restUrl + 'contact-form-7/v1/contact-forms/' + window.linkoneCF7.sampleFormId + '/feedback';
fetch(url, { method: 'POST', body: form, credentials: 'same-origin' })
.then(r => r.json())
.then(res => {
if (res.status === 'mail_sent' || res.status === 'mail_failed') {
setDone(true);
} else {
// Validation issue: show message but still mark done with a soft warning
console.warn('CF7 response:', res);
setDone(true);
}
})
.catch(err => {
console.error('CF7 submit error:', err);
setDone(true); // optimistic
});
} else {
// Fallback: just mark complete (dev / no CF7 installed)
setDone(true);
}
};
if(done){
return (
{t('依頼を受け付けました', 'Request received')}
{t(`${data.company} 様、ありがとうございます。担当者より3営業日以内にご連絡を差し上げます。`,
`Thank you, ${data.company}. A LinkOne member will reach out within 3 business days.`)}
{ setStep(0); setDone(false); setData({company:'',name:'',email:'',phone:'',role:'',origins:[],quantity:'small',process:[],purpose:'',timing:'asap',notes:''}); }}>
{t('別の依頼を作成', 'New request')}
);
}
return (
<>
{steps.map((s, i) => (
{/* Step 0 - Origins */}
{step === 0 && (
)}
{/* Step 1 - Detail */}
{step === 1 && (
{t('使用目的・想定する用途', 'Intended use')} *
{t('希望サンプル量', 'Sample size')}
{[
{v:'small', lab: t('100g 程度','~100g'), sub: t('テイスティング向け','For tasting')},
{v:'medium', lab: t('300g - 500g','300g – 500g'), sub: t('焙煎テスト向け','Roast testing')},
{v:'large', lab: t('1kg 以上','1kg or more'), sub: t('量産前の検証','Pre-production')}
].map(o => (
update('quantity', o.v)}/>
{o.lab} {o.sub}
))}
{t('ご希望の時期', 'Timing')}
update('timing', e.target.value)}>
{t('できるだけ早く', 'As soon as possible')}
{t('1ヶ月以内', 'Within 1 month')}
{t('3ヶ月以内', 'Within 3 months')}
{t('時期は柔軟', 'Flexible')}
{t('その他ご要望(任意)', 'Other notes (optional)')}
)}
{/* Step 2 - Contact */}
{step === 2 && (
Email *
update('email', e.target.value)}/>
{t('依頼内容の確認', 'Request summary')}
{t('産地', 'Origins')}: {data.origins.map(id => ORIGINS.find(o=>o.id===id).name[lang]).join(' · ') || '—'}
{data.process.length > 0 &&
{t('精製', 'Process')}: {data.process.join(' · ')}
}
{t('量', 'Quantity')}: {data.quantity === 'small' ? t('100g 程度','~100g') : data.quantity === 'medium' ? '300g–500g' : '1kg+'}
)}
{t(`ステップ ${step+1} / ${steps.length}`, `Step ${step+1} of ${steps.length}`)}
{step > 0 && setStep(step-1)}>← {t('戻る','Back')} }
{step < steps.length-1 && setStep(step+1)} style={{opacity:canNext()?1:.4}}>{t('次へ','Next')} → }
{step === steps.length-1 && {t('送信する','Submit')} → }
>
);
};
// ================== EVENT REGISTRATION ==================
const EventDetail = () => {
const lang = useLang();
const [open, setOpen] = useState(false);
const [step, setStep] = useState(0);
const [data, setData] = useState({ name:'', company:'', email:'', session:'am', count:1 });
const [done, setDone] = useState(false);
const t = (jp, en) => lang === 'jp' ? jp : en;
const update = (k, v) => setData(d => ({ ...d, [k]: v }));
return (
<>
第一回 イベント Inaugural Event
SCAJ2025 期間中、 東京で初開催。
Tokyo, alongsideSCAJ 2025.
初の試みとなる合同カッピング会。LinkOne 加盟5社のシグネチャーロットを、午前部・午後部の2回、計3ラウンドのカッピングで体験いただけます。
The first joint cupping. Five LinkOne members present their signature lots across three cupping rounds, in AM and PM sessions.
{t('日付','Date')} 2025.09.28 (Sun)
{t('会場','Venue')} UCC Coffee Academy Tokyo
{t('時間','Time')} {t('午前部 / 午後部','AM / PM Session')}
{t('カッピング','Cuppings')} {t('3 ラウンド','3 Rounds')}
{open && (
setOpen(false)} style={{position:'fixed',inset:0,background:'rgba(14,13,11,.72)',zIndex:80,display:'grid',placeItems:'center',padding:'20px',backdropFilter:'blur(6px)'}}>
e.stopPropagation()} style={{background:'var(--bg)',maxWidth:560,width:'100%',padding:'40px 36px',position:'relative',maxHeight:'90vh',overflowY:'auto'}}>
setOpen(false)} style={{position:'absolute',top:14,right:14,width:32,height:32,display:'grid',placeItems:'center',color:'var(--ink-2)'}} aria-label="Close">
{!done ? (
<>
RSVP · 2025.09.28
{t('合同カッピング会・参加申込','Joint Cupping RSVP')}
0?'done':''}`}>
{step>0?'✓':1}
{t('セッション','Session')}
{step===0 && (
<>
{t('参加人数','Number of attendees')}
update('count', +e.target.value)}>
{[1,2,3,4].map(n => {n} {t('名','attendee'+(n>1?'s':''))} )}
>
)}
{step===1 && (
<>
{t('会社・屋号','Company / Roastery')} *
update('company',e.target.value)}/>
{t('お名前','Name')} *
update('name',e.target.value)}/>
Email *
update('email',e.target.value)}/>
>
)}
{step+1} / 2
{step>0 && setStep(0)}>← {t('戻る','Back')} }
{step===0 && setStep(1)}>{t('次へ','Next')} → }
{step===1 && (
{
// Submit to Contact Form 7 (event form) if configured
if (window.linkoneCF7 && window.linkoneCF7.eventFormId && window.linkoneCF7.restUrl) {
const form = new FormData();
form.append('your-company', data.company);
form.append('your-name', data.name);
form.append('your-email', data.email);
form.append('your-session', data.session === 'am' ? 'AM Session (10:00-12:00)' : 'PM Session (14:00-16:00)');
form.append('your-count', String(data.count));
const url = window.linkoneCF7.restUrl + 'contact-form-7/v1/contact-forms/' + window.linkoneCF7.eventFormId + '/feedback';
fetch(url, { method:'POST', body:form, credentials:'same-origin' })
.then(()=>setDone(true))
.catch(()=>setDone(true));
} else {
setDone(true);
}
}}>
{t('申し込む','Confirm RSVP')} →
)}
>
) : (
{t('お申込みを受付けました','RSVP confirmed')}
{t(`${data.session==='am'?'午前部':'午後部'} ・ ${data.count}名 でお席をお取りしました。詳細をメールでお送りします。`,
`${data.session==='am'?'AM':'PM'} session · ${data.count} attendee(s) reserved. Details sent to your email.`)}
setOpen(false)}>{t('閉じる','Close')}
)}
)}
>
);
};
// ================== TWEAKS ==================
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentColor": "#C84B1F",
"showOrbit": true,
"compactHero": false
}/*EDITMODE-END*/;
const Tweaks = () => {
if(!window.useTweaks) return null;
const [t, set] = window.useTweaks(TWEAK_DEFAULTS);
useEffect(() => {
document.documentElement.style.setProperty('--hi', t.accentColor);
const orbit = document.getElementById('hero-orbit');
if(orbit) orbit.style.display = t.showOrbit ? '' : 'none';
}, [t]);
const { TweaksPanel, TweakSection, TweakColor, TweakToggle } = window;
return (
set('accentColor', v)}/>
set('showOrbit', v)}/>
);
};
// ================== MOUNT ==================
function mount(){
const originRoot = document.getElementById('origin-app');
const tabsRoot = document.getElementById('origin-tabs');
const evRoot = document.getElementById('event-detail');
const formRoot = document.getElementById('sample-form');
if(originRoot){
// Replace both origin-app and origin-tabs with single combined module that owns state
const wrap = document.createElement('div');
originRoot.parentNode.insertBefore(wrap, originRoot);
originRoot.remove();
if(tabsRoot) tabsRoot.remove();
ReactDOM.createRoot(wrap).render(
);
}
if(evRoot) ReactDOM.createRoot(evRoot).render(
);
if(formRoot) ReactDOM.createRoot(formRoot).render(
);
// tweaks
const tweakRoot = document.createElement('div');
document.body.appendChild(tweakRoot);
ReactDOM.createRoot(tweakRoot).render(
);
}
mount();