PageSourceSearch

https://www.ajec.or.jp/wp/wp-content/themes/ajec3/assets/chatbot/chatbot.js?ver=1789624779

js ajec.or.jp collected 2026-10-02 19:07:38 UTC 55,911 bytes, 1,079 lines download raw bytes

1/**
2 * AJEC ご案内チャット(ルールベース・AI 応答なし)
3 *
4 * educon-service-site の自作チャットウィジェットを AJEC 向けに移植したもの。
5 * 会話の分岐・文言の正はこのファイルの NODES / CHIPS / SUGGESTS / ROUTES(変更手順は doc/chatbot/README.md)。
6 * 文言はサイトに掲載している事実だけで書く(会費・資格=入会案内、概要=AJECとは、部会=部会の紹介 等)。
7 * 元ページを改訂したらここも直し、SCENARIO_VER を上げる。
8 *
9 * 設定は inc/chatbot.php が window.AJEC_CHATBOT に渡す(REST の URL・案内先 URL・講座一覧・問い合わせの種類)。
10 * 依存なし・vanilla JS。リンクはクリックの同期処理で window.open するためポップアップブロックされない。
11 */
12(function () {
13    'use strict';
14
15    var CFG = window.AJEC_CHATBOT || {};
16    var URLS = CFG.urls || {};
17    var LEAD_TYPES = CFG.leadTypes || { other: 'お問い合わせ' };
18
19    /* シナリオ改訂時に上げる。保存済みの会話とバージョンが違えば破棄して新しいシナリオで始める */
20    var SCENARIO_VER = 2; // 2: デザイン刷新(保存済みの選択肢の形式が変わったため)
21
22    /* ===== シナリオ =====
23     * 選択肢: go=ノードへ進む / url=ページを新しいタブで開く(URLS のキーか絶対 URL。tag=会員社一覧の分野絞り込み)
24     *         act=lead(チャットで問い合わせ。preset=種類)/ end(終了)/ navback(ひとつ前に戻る)
25     * goal(管理画面の集計区分): form=フォームのページ / members=会員社一覧 / kouza=講座の申し込み / detail=その他のページ
26     */
27    function LEAD(preset, label) {
28        return { t: label || 'チャットで問い合わせる', act: 'lead', preset: preset || '' };
29    }
30    function MEMBERS(label, tag) {
31        return { t: label, url: 'members', tag: tag || '', goal: 'members' };
32    }
33
34    var NODES = {
35        n0: {
36            text: [
37                'こんにちは。日本編集制作協会(AJEC)のご案内チャットです。',
38                'ご用件に近いものをメニューからお選びください。下の入力欄に知りたいことを入力して探すこともできます。'
39            ],
40            choices: [
41                { t: '編集制作の仕事を依頼したい', go: 'work' },
42                { t: 'AJECに入会したい', go: 'join' },
43                { t: '編集講座に参加したい', go: 'kouza' },
44                { t: '編集プロダクションフェアについて', go: 'fair' },
45                { t: '契約書のヒナ型・料金基準表', go: 'contract' },
46                { t: '求人情報を見たい', go: 'jobs' },
47                { t: 'AJECについて知りたい', go: 'about' },
48                { t: '会員社の方(ログイン)', go: 'member' },
49                { t: '事務局に問い合わせる', go: 'contact' }
50            ],
51            noBack: true
52        },
53        work: {
54            text: [
55                '編集制作のお仕事のご依頼をご検討いただき、ありがとうございます。AJECには「企画・編集・制作」を核として事業を営む会社が集まっています。',
56                '分野から会員社を探して直接ご相談いただけるほか、事務局にご依頼内容をお送りいただくこともできます。'
57            ],
58            choices: [
59                { t: '分野から会員社を探す', go: 'workGenre' },
60                LEAD('work', 'チャットで依頼内容を送る'),
61                { t: '仕事の依頼フォームへ', url: 'formWork', goal: 'form' }
62            ]
63        },
64        workGenre: {
65            text: [
66                'ご依頼をお考えの分野をお選びください。',
67                'その分野を取り扱う正会員に絞り込んだ一覧を、新しいタブで開きます。'
68            ],
69            choices: [
70                MEMBERS('学習教材(幼児〜大学・社会人)', 'yoji-kyozai,shochuko-kyozai,daigaku-kyozai'),
71                MEMBERS('一般書籍・実用書・専門書・ムック', 'ippan-shoseki,jitsuyo-senmon,mook'),
72                MEMBERS('児童書・絵本・事典・図鑑', 'jido-ehon,jiten-zukan'),
73                MEMBERS('コミック', 'comic'),
74                MEMBERS('新聞・雑誌', 'shinbun-magazine'),
75                MEMBERS('社内報・PR誌・社史・会社や学校の案内', 'shanaiho-pr,shashi-kinen,kaisha-gakko-annai'),
76                MEMBERS('広告・PR・イベント', 'pr-kokoku,event-seminar'),
77                MEMBERS('デジタルコンテンツ・撮影・動画', 'digital-content,photo-video'),
78                MEMBERS('翻訳・コンサルティング', 'translation,consulting'),
79                MEMBERS('分野を問わず正会員の一覧を見る', '')
80            ]
81        },
82        join: {
83            text: [
84                'AJECへの入会をご検討いただき、ありがとうございます。会員には「正会員」「準会員」「賛助会員」の区分があります。',
85                '正会員は、各種情報媒体の企画制作を主な業務として行う法人が対象です。協会の主旨に賛同し、「法人実績2年以上」「社員総数5人以上」「理事会の特別な承認」のいずれか一つ以上を満たせば入会できます。',
86                '手続きは、入会申込書と会社経歴書をご提出いただき、理事会の承認を受けて入会決定となります。'
87            ],
88            choices: [
89                { t: '会費を知りたい', go: 'joinFee' },
90                { t: '準会員・賛助会員について', go: 'joinOther' },
91                { t: '入会案内のページを見る', url: 'admission', goal: 'detail' },
92                { t: '入会申込フォームへ', url: 'formAdmission', goal: 'form' },
93                LEAD('join', 'チャットで入会について問い合わせる')
94            ]
95        },
96        joinFee: {
97            text: [
98                '会費は次のとおりです(入会案内のページに掲載している内容です)。',
99                '正会員:入会金3万円、会費年額9万円\n準会員:法人会員は入会金1万円・年会費3万円、個人会員は年間2万円\n賛助会員:法人・個人ともに入会金5万円、1口5万円'
100            ],
101            choices: [
102                { t: '入会案内のページを見る', url: 'admission', goal: 'detail' },
103                { t: '入会申込フォームへ', url: 'formAdmission', goal: 'form' },
104                LEAD('join', 'チャットで入会について問い合わせる')
105            ]
106        },
107        joinOther: {
108            text: [
109                '準会員は、協会の趣旨・目的に賛同する関連業界の法人または個人が、理事会の承認を得て加入できる区分です(議決権はありません)。理事会に申し出て承認を受ければ、正会員に移行することもできます。',
110                '賛助会員は、協会の趣旨・目的に賛同する法人が、本会の活動に参加できる区分です(議決権はありません)。どちらも入会申込書(正会員1社の推薦)と会社経歴書をご提出いただき、理事会の承認を受けて入会決定となります。'
111            ],
112            choices: [
113                { t: '会費を知りたい', go: 'joinFee' },
114                { t: '入会案内のページを見る', url: 'admission', goal: 'detail' },
115                LEAD('join', 'チャットで入会について問い合わせる')
116            ]
117        },
118        /* 講座はトップページ「開催中の講座」の設定(管理画面で更新)から毎回組み立てる */
119        kouza: function () {
120            var events = CFG.events || {};
121            var upcoming = events.upcoming || [];
122            var archive = events.archive || [];
123            var choices = [];
124            upcoming.forEach(function (e) {
125                choices.push({ t: e.title || 'これから開催予定の講座', url: e.url, goal: 'kouza' });
126            });
127            archive.forEach(function (e) {
128                var title = e.title || '講座';
129                choices.push({ t: title.indexOf('アーカイブ') === -1 ? '【アーカイブ配信】' + title : title, url: e.url, goal: 'kouza' });
130            });
131            choices.push(LEAD('kouza', 'チャットで講座について問い合わせる'));
132            return {
133                text: [
134                    'AJECでは、第一線で活躍する編集者や業界の方を講師にお招きして、編集講座を開催しています。',
135                    upcoming.length || archive.length
136                        ? '現在ご案内している講座は次のとおりです。お申し込みは Peatix のページで受け付けています。'
137                        : '現在、お申し込みを受け付けている講座はありません。講座の情報はトップページの「開催中の講座」でご案内しています。'
138                ],
139                choices: choices
140            };
141        },
142        fair: {
143            text: [
144                '「編集プロダクションフェア」は、編集の仕事を外部に委託しようとしている出版社・印刷会社・企業の方に、編集プロダクションをもっと知っていただくために開催しているイベントです。',
145                'AJECの会員社を中心に編集作品や業務内容を紹介し、ご質問に直接お答えしています。出版・編集に関心のある学生の方や、編集の仕事に興味のある方にも、編集プロダクションの活動を知っていただく場です。'
146            ],
147            choices: [
148                { t: '直近の開催案内を見る', url: 'fair', goal: 'detail' },
149                LEAD('kouza', 'チャットでフェアについて問い合わせる')
150            ]
151        },
152        contract: {
153            text: [
154                'AJECでは、編集制作の取引に役立つ資料を公開しています。',
155                '顧問弁護士の監修による「業務委託基本契約書」のヒナ型(4種類)と、編集制作にかかわる料金の目安をまとめた「編集制作業務料金基準表」をダウンロードいただけます。「業務契約について」のページでは、改正下請法にもとづく取引時の注意点をご紹介しています。'
156            ],
157            choices: [
158                { t: '契約書のヒナ型を見る', url: 'template', goal: 'detail' },
159                { t: '料金基準表を見る', url: 'fee', goal: 'detail' },
160                { t: '業務契約について(下請法)', url: 'contract', goal: 'detail' }
161            ]
162        },
163        jobs: {
164            text: [
165                'AJEC会員社の求人・採用情報を掲載しています。',
166                '募集の詳しい内容は、各社のページでご確認ください。会員社の一覧から各社のページをご覧いただくこともできます。'
167            ],
168            choices: [
169                { t: '求人情報を見る', url: 'jobs', goal: 'detail' },
170                MEMBERS('正会員の一覧を見る', '')
171            ]
172        },
173        about: {
174            text: [
175                '一般社団法人 日本編集制作協会(AJEC)は、「企画・編集・制作」を核として事業を営む会社が集まる業界団体です。1983年4月に創設されました。',
176                '業界内外の情報交換や会員相互間の業務提携・交流のほか、編集講座や編集プロダクションフェアの開催、経営者のための勉強会などを行っています。会員社の特性ごとに、一般書部会・CC部会・教材部会・全国部会の4つの部会があります。'
177            ],
178            choices: [
179                { t: 'AJECとは(協会概要)', url: 'about', goal: 'detail' },
180                { t: '部会の紹介', url: 'introduction', goal: 'detail' },
181                MEMBERS('正会員の一覧を見る', '')
182            ]
183        },
184        member: {
185            text: [
186                '会員社の方は、会員ログインからマイページにお入りください。',
187                'パスワードをお忘れの場合や、パスワード設定のリンクの期限が切れた場合は、事務局までご連絡ください。折り返し、新しいパスワード設定リンクをお送りします。'
188            ],
189            choices: [
190                { t: '会員ログインへ', url: 'memberLogin', goal: 'detail' },
191                LEAD('other', 'チャットで事務局に連絡する')
192            ]
193        },
194        contact: {
195            text: [
196                '事務局へのお問い合わせは、このままチャットでお送りいただけます。',
197                'お問い合わせフォームや、お電話(03-6869-7780)でも承っております。'
198            ],
199            choices: [
200                LEAD(''),
201                { t: 'お問い合わせフォームへ', url: 'formContact', goal: 'form' }
202            ]
203        },
204        shigoto: {
205            text: ['「仕事」について、どちらのご用件でしょうか。'],
206            choices: [
207                { t: '編集制作の仕事を依頼したい', go: 'work' },
208                { t: '求人情報(働く場所)を探している', go: 'jobs' }
209            ]
210        },
211        notFound: {
212            text: [
213                '申し訳ありません。ご質問に合うご案内が見つかりませんでした。',
214                'メニューからお選びいただくか、このままチャットで事務局にお問い合わせください。'
215            ],
216            choices: [LEAD('')]
217        },
218        opened: {
219            text: ['ページを新しいタブで開きました。ほかにご用件はありますか?'],
220            choices: [{ t: '最初のメニューに戻る', go: 'n0' }, { t: '終了する', act: 'end' }],
221            noBack: true
222        },
223        more: {
224            text: ['ほかにご用件はありますか?'],
225            choices: [{ t: '最初のメニューに戻る', go: 'n0' }, { t: '終了する', act: 'end' }],
226            noBack: true
227        }
228    };
229
230    /* よくある質問チップ(入力欄の上に常時表示) */
231    var CHIPS = [
232        { t: '仕事を依頼したい', go: 'work' },
233        { t: '入会・会費', go: 'join' },
234        { t: '編集講座', go: 'kouza' },
235        { t: '契約書・料金基準表', go: 'contract' },
236        { t: '求人情報', go: 'jobs' },
237        { t: '問い合わせ', go: 'contact' }
238    ];
239
240    /* 入力候補: 入力中に最大3件を表示し、選ぶとそのノードへ進む(ひらがなã
240®èª­ã¿ã‚‚登録) */
241    var SUGGESTS = [
242        { t: '編集制作の仕事を依頼したい', k: ['依頼', 'いらい', '発注', 'はっちゅう', '外注', '委託', '頼み', '頼め', 'たのみ', '制作会社', '編集プロダクション', '編プロ'], go: 'work' },
243        { t: '分野から会員社を探す', k: ['会員社', 'かいいんしゃ', '探す', 'さがす', '教材', '書籍', '雑誌', 'コミック', '漫画', 'マンガ', '絵本', '図鑑', '社史', '社内報', '翻訳', '動画'], go: 'workGenre' },
244        { t: 'AJECに入会したい', k: ['入会', 'にゅうかい', '加入', '会員になり', '資格', '準会員', '賛助'], go: 'join' },
245        { t: '会費を知りたい', k: ['会費', 'かいひ', '入会金', '年会費'], go: 'joinFee' },
246        { t: '編集講座に参加したい', k: ['講座', 'こうざ', 'セミナー', '研修', '勉強会', '申し込み', '申込', 'もうしこみ', 'peatix'], go: 'kouza' },
247        { t: '編集プロダクションフェアについて', k: ['フェア', 'ふぇあ', '展示会', 'イベント'], go: 'fair' },
248        { t: '契約書のヒナ型・料金基準表', k: ['契約', 'けいやく', 'ヒナ型', 'ひな型', '雛形', 'ひながた', '下請', '料金', 'りょうきん', '単価', '相場', '基準表'], go: 'contract' },
249        { t: '求人情報を見たい', k: ['求人', 'きゅうじん', '採用', 'さいよう', '就職', '転職', '応募', '新卒', '働き'], go: 'jobs' },
250        { t: 'AJECについて知りたい', k: ['ajec', '協会', 'きょうかい', '部会', 'ぶかい', '概要'], go: 'about' },
251        { t: '会員ログイン・パスワード', k: ['ログイン', 'ろぐいん', 'パスワード', 'ぱすわーど', 'マイページ'], go: 'member' },
252        { t: '事務局に問い合わせる', k: ['問い合わせ', '問合せ', 'といあわせ', '相談', 'そうだん', '連絡', '電話', '事務局'], go: 'contact' }
253    ];
254
255    /* 自由入力のキーワード分岐(上から順に評価。含まれていればそのノードへ) */
256    var ROUTES = [
257        [['会費', '入会金', '年会費'], 'joinFee'],
258        [['入会', '加入', '会員になり', '準会員', '賛助'], 'join'],
259        [['ログイン', 'パスワード', 'マイページ'], 'member'],
260        [['講座', 'セミナー', '研修', '勉強会'], 'kouza'],
261        [['フェア', '展示会'], 'fair'],
262        [['契約', 'ヒナ型', 'ひな型', '雛形', '下請', '料金', '単価', '相場', '基準表'], 'contract'],
263        [['求人', '採用', '就職', '転職', '応募', '新卒'], 'jobs'],
264        [['依頼', '発注', '外注', '委託', '頼み', '頼め', '制作会社', 'プロダクション', '編プロ'], 'work'],
265        [['教材', '書籍', '雑誌', 'コミック', '漫画', 'マンガ', '絵本', '図鑑', '社史', '社内報', '翻訳', '動画', '会員社'], 'workGenre'],
266        [['仕事'], 'shigoto'],
267        [['問い合わせ', '問合せ', '相談', '連絡', '電話', '事務局'], 'contact'],
268        [['ajec', '協会', '部会', 'どんな'], 'about']
269    ];
270
271    /* 全角英数・記号を半角にそろえる */
272    function nfkc(s) {
273        s = String(s || '');
274        return s.normalize ? s.normalize('NFKC') : s;
275    }
276    /* キーワード照合用(半角にそろえて英字は小文字) */
277    function norm(s) {
278        return nfkc(s).toLowerCase();
279    }
280
281    function routeInput(q) {
282        var nq = norm(q);
283        for (var i = 0; i < ROUTES.length; i++) {
284            for (var j = 0; j < ROUTES[i][0].length; j++) {
285                if (nq.indexOf(norm(ROUTES[i][0][j])) !== -1) {
286                    return { to: ROUTES[i][1], matched: ROUTES[i][0][j] };
287                }
288            }
289        }
290        return { to: 'notFound', matched: null };
291    }
292
293    function matchSuggests(q) {
294        var nq = norm(q);
295        var hits = [];
296        if (!nq) { return hits; }
297        for (var i = 0; i < SUGGESTS.length && hits.length < 3; i++) {
298            var s = SUGGESTS[i];
299            var hit = norm(s.t).indexOf(nq) !== -1;
300            for (var j = 0; !hit && j < s.k.length; j++) {
301                var k = norm(s.k[j]);
302                hit = k.indexOf(nq) !== -1 || nq.indexOf(k) !== -1;
303            }
304            if (hit) { hits.push(s); }
305        }
306        return hits;
307    }
308
309    /* 問い合わせフォームカードの項目(送信先の検証は inc/chatbot-lead.php) */
310    var LEAD_FIELDS = [
311        { key: 'company', label: '会社・団体名', ph: '例:株式会社〇〇(個人の方は空欄)', optional: true, auto: 'organization' },
312        { key: 'name', label: 'お名前', ph: '例:山田 太
312郎', auto: 'name' },
313        { key: 'kana', label: 'ふりがな', ph: '例:やまだ たろう' },
314        { key: 'email', label: 'メールアドレス', ph: '例:[email protected]', type: 'email', auto: 'email' },
315        { key: 'tel', label: '電話番号', ph: '例:03-1234-5678', type: 'tel', optional: true, auto: 'tel' },
316        { key: 'message', label: 'お問い合わせ内容', multiline: true }
317    ];
318    var MESSAGE_PH = {
319        work: '例:小学生向けドリルの編集を依頼したいです。分量や希望の時期など',
320        join: '例:正会員として入会を検討しています。手続きについて伺いたいです',
321        kouza: '例:講座の受講方法について質問があります',
322        other: 'お問い合わせ内容をご記入ください'
323    };
324
325    var ICONS = {
326        chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
327        minus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>',
328        close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>',
329        plane: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>',
330        // 新しいタブで開くリンクの印(文字の「↗」は環境によって絵文字で表示されるため SVG)
331        ext: '<svg class="acb-ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>',
332        send: '<svg class="acb-cvicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>',
333        form: '<svg class="acb-cvicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>'
334    };
335
336    /* ===== セッション・ログ ===== */
337    function currentPath() {
338        try { return decodeURI(location.pathname); } catch (e) { return location.pathname; }
339    }
340
341    var session = null;
342    try {
343        session = sessionStorage.getItem('ajecChatSession');
344        if (!session) {
345            session = 'cs-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
346            sessionStorage.setItem('ajecChatSession', session);
347        }
348    } catch (e) { session = 'cs-mem-' + Date.now().toString(36); }
349
350    var logQueue = [];
351    var logTimer = null;
352    function log(type, data) {
353        if (!CFG.restLog) { return; }
354        logQueue.push({ t: type, d: data || {} });
355        clearTimeout(logTimer);
356        logTimer = setTimeout(flushLog, 2000);
357    }
358    function flushLog() {
359        clearTimeout(logTimer);
360        while (logQueue.length && CFG.restLog) {
361            var payload = JSON.stringify({ session: session, page: currentPath(), events: logQueue.splice(0, 20) });
362            try {
363                navigator.sendBeacon(CFG.restLog, new Blob([payload], { type: 'application/json' }));
364            } catch (e) { /* noop */ }
365        }
366    }
367    window.addEventListener('pagehide', flushLog);
368
369    /* ===== 会話の保存(ページを移っても続く。タブを閉じると消える) ===== */
370    var STORE_KEY = 'ajecChatState';
371    var state = { msgs: [], choices: null, started: false, lead: null, nav: [] };
372    function persist() {
373        try {
374            sessionStorage.setItem(STORE_KEY, JSON.stringify({
375                v: SCENARIO_VER,
376                msgs: state.msgs.slice(-60),
377                choices: state.choices,
378                started: state.started,
379                lead: state.lead,
380                nav: state.nav
381            }));
382        } catch (e) { /* noop */ }
383    }
384    function restoreState() {
385        try {
386            var d = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null');
387            if (!d) { return; }
388            if (d.v !== SCENARIO_VER || !Array.isArray(d.msgs)) { sessionStorage.removeItem(STORE_KEY); return; }
389            state.msgs = d.msgs;
390            state.choices = Array.isArray(d.choices) ? d.choices : null;
391            state.started = !!d.started;
392            state.lead = d.lead && d.lead.step ? d.lead : null;
393            state.nav = Array.isArray(d.nav) ? d.nav : [];
394        } catch (e) { /* noop */ }
395    }
396    function clearState() {
397        state = { msgs: [], choices: null, started: false, lead: null, nav: [] };
398        try { sessionStorage.removeItem(STORE_KEY); } catch (e) { /* noop */ }
399    }
400
401    /* ===== DOM ===== */
402    function el(tag, cls, text) {
403        var e = document.createElement(tag);
404        if (cls) { e.className = cls; }
405        if (text) { e.textContent = text; }
406        return e;
407    }
408    function fmtTime(ts) {
409        var d = ts ? new Date(ts) : new Date();
410        var h = d.getHours();
411        var m = d.getMinutes();
412        return (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m);
413    }
414
415    // マウス等の精密ポインタ環境か(タッチ端末では開いた瞬間にキーボードを出さない)
416    var FINE_POINTER = !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches);
417
418    /* 見た目は「目次のある案内帳」(chatbot.css の冒頭参照)。PC の入口は画面右端のしおり型タブ */
419    var root = el('div', 'acb');
420    root.innerHTML =
421        '<button type="button" class="acb-launcher" aria-haspopup="dialog" aria-expanded="false" aria-label="ご案内チャットを開く">' +
422            '<span class="acb-launcher__icon">' + ICONS.chat + '</span>' +
423            '<span class="acb-launcher__label" aria-hidden="true">ご案内チャット</span>' +
424        '</button>' +
425        '<section class="acb-panel" role="dialog" aria-label="AJEC ご案内チャット" tabindex="-1" hidden>' +
426        '<div class="acb-sheet">' +
427            '<header class="acb-head">' +
428                '<div class="acb-head__top">' +
429                    '<span class="acb-head__kicker">GUIDE</span>' +
430                    '<span class="acb-head__rule" aria-hidden="true"></span>' +
431                    '<button type="button" class="acb-head__btn acb-head__min" aria-label="チャットを最小化(会話は保持されます)">' + ICONS.minus + '</button>' +
432                    '<button type="button" class="acb-head__btn acb-head__close" aria-label="チャットを終了">' + ICONS.close + '</button>' +
433                '</div>' +
434                '<p class="acb-head__title">ご案内チャット</p>' +
435                '<p class="acb-head__sub">日本編集制作協会|自動応答でご案内します</p>' +
436            '</header>' +
437            '<div class="acb-body" role="log" aria-live="polite"></div>' +
438            '<div class="acb-confirm" hidden>' +
439                '<div class="acb-confirm__box">' +
440                    '<div class="acb-confirm__ask">' +
441                        '<p>チャットを終了しますか?</p>' +
442                        '<p class="acb-confirm__note">終了すると会話の内容は消去されます。</p>' +
443                        '<div class="acb-confirm__btns">' +
444                            '<button type="button" class="acb-confirm__yes">終了する</button>' +
445                            '<button type="button" class="acb-confirm__no">キャンセル</button>' +
446                        '</div>' +
447                    '</div>' +
448                    '<div class="acb-confirm__fb" hidden>' +
449                        '<p>ご案内は役に立ちましたか?</p>' +
450                        '<div class="acb-confirm__btns">' +
451                            '<button type="button" class="acb-fb__good">役に立った</button>' +
452                            '<button type="button" class="acb-fb__bad">いまいち</button>' +
453                        '</div>' +
454                        '<button type="button" class="acb-fb__skip">回答せずに終了する</button>' +
455                    '</div>' +
456                '</div>' +
457            '</div>' +
458            '<footer class="acb-foot">' +
459                '<div class="acb-suggest" role="group" aria-label="入力候補" hidden></div>' +
460                '<div class="acb-index-row">' +
461                    '<span class="acb-foot__kicker" aria-hidden="true">INDEX</span>' +
462                    '<div class="acb-chips" role="group" aria-label="よくある質問"></div>' +
463                '</div>' +
464                '<form class="acb-form">' +
465                    '<input type="text" class="acb-input" maxlength="100" placeholder="知りたいことを入力(例:入会の会費)" aria-label="知りたいことを入力" enterkeyhint="send">' +
466                    '<button type="submit" class="acb-send" aria-label="送信">' + ICONS.plane + '</button>' +
467                '</form>' +
468            '</footer>' +
469        '</div>' +
470        '</section>';
471
472    var launcher = root.querySelector('.acb-launcher');
473    var panel = root.querySelector('.acb-panel');
474    var body = root.querySelector('.acb-body');
475    var confirmBox = root.querySelector('.acb-confirm');
476    var suggestBox = root.querySelector('.acb-suggest');
477    var chipsBox = root.querySelector('.acb-chips');
478    var form = root.querySelector('.acb-form');
479    var input = root.querySelector('.acb-input');
480
481    var busy = false;
482    function setBusy(next) {
483        busy = next;
484        root.classList.toggle('acb--busy', next);
485    }
486
487    /* ===== メッセージ描画(norecord=保存しない=復元時用) ===== */
488    function scrollBottom() { body.scrollTop = body.scrollHeight; }
489
490    function addTurn(side, text, opts) {
491        opts = opts || {};
492        var prev = body.lastElementChild;
493        var wrap = el('div', 'acb-turn acb-turn--' + side);
494        if (side === 'bot') {
495            // 案内文のまとまりの先頭にだけ「案」の印・差出人・時刻を付ける(続く案内文は本文だけ)
496            if (!(prev && prev.classList.contains('acb-turn--bot'))) {
497                var byline = el('div', 'acb-byline');
498                byline.appendChild(el('span', 'acb-byline__seal', '案'));
499                byline.appendChild(el('span', 'acb-byline__name', 'AJEC ご案内'));
500                byline.appendChild(el('span', 'acb-byline__time', fmtTime(opts.ts)));
501                wrap.appendChild(byline);
502            }
503            wrap.appendChild(el('div', 'acb-msg acb-msg--bot', text));
504        } else {
505            wrap.appendChild(el('div', 'acb-msg acb-msg--user', text));
506            wrap.appendChild(el('div', 'acb-time', fmtTime(opts.ts)));
507        }
508        body.appendChild(wrap);
509        scrollBottom();
510        if (!opts.norecord) {
511            state.msgs.push({ k: side, v: text, ts: Date.now() });
512            persist();
513        }
514    }
515    function addBot(text, opts) { addTurn('bot', text, opts); }
516    function addUser(text, opts) { addTurn('user', text, opts); }
517
518    function resolveUrl(c) {
519        var base = Object.prototype.hasOwnProperty.call(URLS, c.url) ? URLS[c.url] : (/^https?:\/\//.test(c.url) ? c.url : '');
520        if (!base) { return ''; }
521        if (c.tag) { base += (base.indexOf('?') === -1 ? '?' : '&') + 'tag=' + c.tag; }
522        return base;
523    }
524
525    function clearChoices() {
526        body.querySelectorAll('.acb-choices:not(.acb-lead-ui)').forEach(function (box) {
527            box.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
528            box.classList.add('acb-choices--done');
529        });
530        state.choices = null;
531    }
532
533    /* 目次の1行(番号・見出し・リーダー罫・矢印) */
534    function tocRow(label, no, isLink) {
535        var b = el('button', 'acb-toc__row' + (isLink ? ' acb-toc__row--link' : ''));
536        b.type = 'button';
537        b.appendChild(el('span', 'acb-toc__no', (no < 10 ? '0' : '') + no));
538        b.appendChild(el('span', 'acb-toc__label', label));
539        b.insertAdjacentHTML('beforeend', '<span class="acb-toc__leader" aria-hidden="true"></span><span class="acb-toc__mark" aria-hidden="true">' + (isLink ? ICONS.ext : '›') + '</span>');
540        return b;
541    }
542
543    /* 戻る・終了などの小さなテキストリンク */
544    function navLink(label, kind) {
545        var b = el('button', 'acb-navlink acb-navlink--' + kind, label);
546        b.type = 'button';
547        return b;
548    }
549
550    /*
551     * 選択肢の描画。種類ごとに並べ分ける:
552     *   目次(番号付きの行)/インデックス(会員社の分野=色付きタブ)/アクション(問い合わせ・フォーム)/ナビ(戻る・終了)
553     */
554    function renderChoices(choices, opts) {
555        opts = opts || {};
556        // 案内先 URL が無い環境(例: フェアの静的ページが置かれていない)ではその選択肢を出さない
557        var list = (choices || []).filter(function (c) { return !c.url || resolveUrl(c); });
558        if (!list.length) { return; }
559
560        var box = el('div', 'acb-choices');
561        var toc = el('div', 'acb-toc');
562        var index = el('div', 'acb-index');
563        var actions = el('div', 'acb-actions');
564        var nav = el('div', 'acb-nav');
565        var no = 0;
566        var tags = 0;
567
568        list.forEach(function (c) {
569            var b;
570            if (c.nav || c.act === 'end') {
571                b = navLink(c.t, c.act === 'navback' ? 'back' : (c.act === 'end' ? 'end' : 'top'));
572                nav.appendChild(b);
573            } else if (c.act === 'lead') {
574                // チャットで問い合わせ(塗り)はフォームへのリンク(枠線)より前に置く
575                b = el('button', 'acb-action acb-action--primary', c.t);
576                b.insertAdjacentHTML('afterbegin', ICONS.send);
577                actions.insertBefore(b, actions.querySelector('.acb-action--link'));
578            } else if (c.goal === 'form') {
579                b = el('button', 'acb-action acb-action--link', c.t);
580                b.insertAdjacentHTML('afterbegin', ICONS.form);
581                b.insertAdjacentHTML('beforeend', ICONS.ext);
582                actions.appendChild(b);
583            } else if (c.goal === 'members' && c.url === 'members') {
584                b = el('button', 'acb-index__tag acb-index__tag--c' + (tags++ % 4), c.t);
585                index.appendChild(b);
586            } else {
587                b = tocRow(c.t, ++no, !!c.url);
588                toc.appendChild(b);
589            }
590            b.type = 'button';
591            if (c.url) { b.setAttribute('aria-label', c.t + '(新しいタブで開きます)'); }
592            b.addEventListener('click', function () {
593                if (busy) { return; }
594                if (c.act !== 'end') { b.classList.add('is-picked'); }
594 // 選んだ項目に赤線(校正の印)
595                onChoice(c);
596            });
597        });
598
599        [toc, index, actions, nav].forEach(function (group) {
600            if (group.children.length) { box.appendChild(group); }
601        });
602        body.appendChild(box);
603        scrollBottom();
604        state.choices = list;
605        if (!opts.norecord) { persist(); }
606    }
607
608    /* ===== ノード進行 ===== */
609    var renderSeq = 0; // 表示途中に別の操作があったら、古い表示の続きを出さない
610
611    function showNode(id, instant) {
612        var node = typeof NODES[id] === 'function' ? NODES[id]() : NODES[id];
613        if (!node) { return; }
614        // 「ひとつ前に戻る」用の履歴。n0 でリセット、戻るときの再表示は積まない
615        if (id === 'n0') { state.nav = ['n0']; }
616        else if (state.nav[state.nav.length - 1] !== id) {
617            state.nav.push(id);
618            if (state.nav.length > 20) { state.nav = state.nav.slice(-20); }
619        }
620
621        clearChoices();
622        var seq = ++renderSeq;
623        var first = !body.querySelector('.acb-turn--bot');
624        var typing = el('div', 'acb-typing');
625        typing.setAttribute('aria-label', '入力中');
626        typing.innerHTML = '<i>・</i><i>・</i><i>・</i>';
627        body.appendChild(typing);
628        scrollBottom();
629
630        setTimeout(function () {
631            typing.remove();
632            if (seq !== renderSeq) { return; }
633            (function next(i) {
634                if (seq !== renderSeq) { return; }
635                if (i < node.text.length) {
636                    addBot(node.text[i]);
637                    setTimeout(function () { next(i + 1); }, instant ? 0 : 350);
638                    return;
639                }
640                var choices = node.choices.slice();
641                if (!node.noBack) {
642                    var prev = state.nav.length >= 2 ? state.nav[state.nav.length - 2] : null;
643                    if (prev && prev !== 'n0') { choices.push({ t: 'ひとつ前に戻る', act: 'navback', nav: true }); }
644                    choices.push({ t: '最初のメニューに戻る', go: 'n0', nav: true });
645                }
646                renderChoices(choices);
647                // 最初の挨拶は頭から読めるよう先頭を表示
648                if (first) { body.scrollTop = 0; }
649            })(0);
650        }, instant ? 0 : 400);
651    }
652
653    function onChoice(c) {
654        if (busy) { return; }
655        if (c.act === 'end') { showConfirm(); return; }
656        addUser(c.t);
657        if (c.act === 'lead') {
658            log('choice', { label: c.t });
659            startLead(c.preset);
660            return;
661        }
662        if (c.act === 'navback') {
663            state.nav.pop();
664            var prevId = state.nav[state.nav.length - 1] || 'n0';
665            log('choice', { label: c.t, to: prevId });
666            showNode(prevId);
667            return;
668        }
669        if (c.url) {
670            var dest = resolveUrl(c);
671            // クリックの同期処理で開く=ポップアップブロックされない
672            window.open(dest, '_blank', 'noopener');
673            log('goal', { goal: c.goal || 'detail', label: c.t, url: dest.replace(location.origin, '') });
674            showNode('opened');
675            return;
676        }
677        log('choice', { label: c.t, to: c.go });
678        showNode(c.go);
679    }
680
681    /* ===== チャット内の問い合わせ(種類を選ぶ→フォームカード→送信) ===== */
682    function leadActive() { return !!(state.lead && state.lead.step); }
683    function setLeadMode(on) { root.classList.toggle('acb--lead', on); }
684    function removeLeadUI() { body.querySelectorAll('.acb-lead-ui').forEach(function (n) { n.remove(); }); }
685
686    function startLead(preset) {
687        clearChoices();
688        var hasPreset = Object.prototype.hasOwnProperty.call(LEAD_TYPES, preset);
689        state.lead = { step: hasPreset ? 'form' : 'type', type: hasPreset ? preset : '', data: {} };
690        persist();
691        setLeadMode(true);
692        log('lead', { ev: 'start', type: state.lead.type });
693        addBot('承知しました。このままチャットで、事務局へお問い合わせをお送りいただけます。');
694        askLeadStep(state.lead.step);
695    }
696
697    function askLeadStep(step) {
698        state.lead.step = step;
699        persist();
700        if (step === 'type') {
701            addBot('お問い合わせの種類をお選びください。');
702            renderLeadTypes();
703        } else {
704            addBot('下のお問い合わせ票にご入力のうえ、「この内容で送信する」を押してください。');
705            renderLeadForm();
706        }
707    }
708
709    function cancelLead() {
710        removeLeadUI();
711        state.lead = null;
712        persist();
713        setLeadMode(false);
714        log('lead', { ev: 'cancel' });
715        addBot('入力を中止しました。引き続きメニューからお選びいただくか、知りたいことを入力してください。');
716        showNode('n0', true);
717    }
718
719    /* 「入力をやめる」リンク(ナビの行に入れて返す) */
720    function leadCancelNav() {
721        var nav = el('div', 'acb-nav');
722        var b = navLink('入力をやめる', 'back');
723        b.addEventListener('click', function () { if (!busy) { cancelLead(); } });
724        nav.appendChild(b);
725        return nav;
726    }
727
728    function renderLeadTypes() {
729        var box = el('div', 'acb-choices acb-lead-ui');
730        var toc = el('div', 'acb-toc');
731        Object.keys(LEAD_TYPES).forEach(function (key, i) {
732            var b = tocRow(LEAD_TYPES[key], i + 1, false);
733            b.addEventListener('click', function () {
734                state.lead.type = key;
735                removeLeadUI();
736                addUser(LEAD_TYPES[key]);
737                askLeadStep('form');
738            });
739            toc.appendChild(b);
740        });
741        box.appendChild(toc);
742        box.appendChild(leadCancelNav());
743        body.appendChild(box);
744        scrollBottom();
745    }
746
747    function renderLeadForm() {
748        var lead = state.lead;
749        if (!Object.prototype.hasOwnProperty.call(LEAD_TYPES, lead.type)) { lead.type = 'other'; }
750        var card = el('div', 'acb-lead-form acb-lead-ui');
751
752        var head = el('div', 'acb-slip__head');
753        head.appendChild(el('span', 'acb-slip__kicker', 'FORM'));
754        head.appendChild(el('span', 'acb-slip__title', 'お問い合わせ票'));
755        head.appendChild(el('span', 'acb-slip__to', '宛先:AJEC 事務局'));
756        card.appendChild(head);
757
758        var row = el('div', 'acb-lf-row');
759        var label = el('label', 'acb-lf-label', 'お問い合わせの種類');
760        label.htmlFor = 'acb-f-type';
761        var select = el('select', 'acb-lf-input');
762        select.id = 'acb-f-type';
763        Object.keys(LEAD_TYPES).forEach(function (key) {
764            var o = el('option', '', LEAD_TYPES[key]);
765            o.value = key;
766            o.selected = key === lead.type;
767            select.appendChild(o);
768        });
769        row.appendChild(label);
770        row.appendChild(select);
771        card.appendChild(row);
772
773        var inputs = {};
774        LEAD_FIELDS.forEach(function (f) {
775            var r = el('div', 'acb-lf-row');
776            var lab = el('label', 'acb-lf-label', f.label);
777            lab.htmlFor = 'acb-f-' + f.key;
778            lab.appendChild(el('span', f.optional ? 'acb-lf-opt' : 'acb-lf-req', f.optional ? '任意' : '必須'));
779            var inp = el(f.multiline ? 'textarea' : 'input', 'acb-lf-input');
780            if (!f.multiline) { inp.type = f.type || 'text'; }
781            inp.id = 'acb-f-' + f.key;
782            inp.placeholder = f.key === 'message' ? (MESSAGE_PH[lead.type] || MESSAGE_PH.other) : f.ph;
783            if (f.auto) { inp.autocomplete = f.auto; }
784            inp.maxLength = f.multiline ? 2000 : (f.key === 'tel' ? 20 : 100);
785            inp.value = lead.data[f.key] || '';
786            inp.addEventListener('input', function () {
787                lead.data[f.key] = inp.value;
788                persist();
789            });
790            inputs[f.key] = inp;
791            r.appendChild(lab);
792            r.appendChild(inp);
793            card.appendChild(r);
794        });
795        select.addEventListener('change', function () {
796            lead.type = select.value;
797            inputs.message.placeholder = MESSAGE_PH[lead.type] || MESSAGE_PH.other;
798            persist();
799        });
800
801        // ハニーポット(人には見えない欄。ボットが埋めたらサーバー側で送らない)
802        var hp = el('div', 'acb-hp');
803        hp.setAttribute('aria-hidden', 'true');
804        hp.innerHTML = '<label>この欄は空欄のまま送信してください<input type="text" tabindex="-1" autocomplete="off"></label>';
805        card.appendChild(hp);
806
807        card.appendChild(el('p', 'acb-lf-note', '送信すると、ご入力のメールアドレスに受付確認のメールをお送りします。'));
808
809        var err = el('div', 'acb-lf-error');
810        err.setAttribute('role', 'alert');
811        err.hidden = true;
812        card.appendChild(err);
813
814        var send = el('button', 'acb-action acb-action--primary acb-lf-send', 'この内容で送信する');
815        send.type = 'button';
816        send.insertAdjacentHTML('afterbegin', ICONS.send);
817        send.addEventListener('click', function () {
818            var message = validateLead(lead.data, inputs);
819            if (message) {
820                showLeadError(err, message);
821                return;
822            }
823            err.hidden = true;
824            submitLead(send, err, hp.querySelector('input').value);
825        });
826        card.appendChild(send);
827        card.appendChild(leadCancelNav());
828        body.appendChild(card);
829        scrollBottom();
830    }
831
832    /* カタカナ→ひらがな(「ふりがな」欄の表記ゆれをそろえる) */
833    function toHiragana(str) {
834        return str.replace(/[ァ-ヶ]/g, function (ch) { return String.fromCharCode(ch.charCo
834deAt(0) - 0x60); });
835    }
836
837    function validateLead(d, inputs) {
838        ['company', 'name', 'kana', 'email', 'tel', 'message'].forEach(function (k) {
839            d[k] = String(d[k] || '').trim();
840        });
841        d.kana = toHiragana(d.kana);
842        d.email = nfkc(d.email).replace(/\s+/g, '');
843        d.tel = nfkc(d.tel).replace(/[ー―‐−–—]/g, '-');
844        ['kana', 'email', 'tel'].forEach(function (k) { inputs[k].value = d[k]; });
845        persist();
846
847        if (!d.name || !d.kana || !d.email || !d.message) {
848            return '必須項目が入力されていません。';
849        }
850        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(d.email)) {
851            return 'メールアドレスの形式が正しくないようです。';
852        }
853        var digits = d.tel.replace(/[^0-9]/g, '');
854        if (d.tel && (/[^0-9+\-() ]/.test(d.tel) || digits.length < 10 || digits.length > 13)) {
855            return '電話番号は市外局番から、数字とハイフンでご入力ください。';
856        }
857        return '';
858    }
859
860    function showLeadError(box, message, withFormLink) {
861        box.textContent = message;
862        if (withFormLink && URLS.formContact) {
863            box.appendChild(document.createTextNode(' 時間をおいて再度お試しいただくか、'));
864            var a = el('a', '', 'お問い合わせフォーム');
865            a.href = URLS.formContact;
866            a.target = '_blank';
867            a.rel = 'noopener';
868            box.appendChild(a);
869            box.appendChild(document.createTextNode('をご利用ください。'));
870        }
871        box.hidden = false;
872        scrollBottom();
873    }
874
875    function submitLead(sendBtn, errBox, honeypot) {
876        if (busy || !CFG.restLead) { return; }
877        var type = state.lead.type;
878        var d = state.lead.data;
879        var sendHtml = sendBtn.innerHTML; // アイコン付きの元の表示(失敗時に戻す)
880        setBusy(true);
881        sendBtn.disabled = true;
882        sendBtn.textContent = '送信しています…';
883
884        var failed = function (message) {
885            setBusy(false);
886            log('lead', { ev: 'error' });
887            sendBtn.disabled = false;
888            sendBtn.innerHTML = sendHtml;
889            showLeadError(errBox, message || '送信に失敗しました。', true);
890        };
891
892        fetch(CFG.restLead, {
893            method: 'POST',
894            headers: { 'Content-Type': 'application/json' },
895            credentials: 'same-origin',
896            body: JSON.stringify({
897                type: type,
898                company: d.company,
899                name: d.name,
900                kana: d.kana,
901                email: d.email,
902                tel: d.tel,
903                message: d.message,
904                website: honeypot,
905                page: currentPath(),
906                session: session
907            })
908        }).then(function (res) {
909            return res.json().catch(function () { return {}; });
910        }).then(function (data) {
911            if (!data || !data.ok) {
912                failed(data && data.message);
913                return;
914            }
915            // 成功時の後処理で例外が起きても「送信失敗」とは表示しない(then の第2引数は通信・解析の失敗だけを受ける)
916            setBusy(false);
917            removeLeadUI();
918            state.lead = null;
919            setLeadMode(false);
920            addUser('入力した内容を送信');
921            log('goal', { goal: 'lead', type: type });
922            flushLog();
923            addBot('お問い合わせを送信しました。ありがとうございます。\nご入力のメールアドレスに受付確認のメールをお送りしました。事務局より折り返しご連絡いたします。');
924            showNode('more');
925        }, function () {
926            failed();
927        });
928    }
929
930    /* ===== 入力欄・チップ・入力候補 ===== */
931    // 日本語 IME の変換確定 Enter で送信しない(Safari は keyCode 229 で判定)
932    var composing = false;
933    input.addEventListener('compositionstart', function () { composing = true; });
934    input.addEventListener('compositionend', function () { composing = false; });
935    input.addEventListener('keydown', function (ev) {
936        if (ev.key === 'Enter' && (composing || ev.isComposing || ev.keyCode === 229)) { ev.preventDefault(); }
937        if (ev.key === 'Escape') { hideSuggest(); }
938    });
939
940    function hideSuggest() {
941        suggestBox.hidden = true;
942        suggestBox.innerHTML = '';
943    }
944    input.addEventListener('input', function () {
945        var hits = matchSuggests(input.value.trim());
946        if (!hits.length || leadActive()) { hideSuggest(); return; }
947        suggestBox.innerHTML = '';
948        hits.forEach(function (s) {
949            var b = el('button', 'acb-suggest__item', s.t);
950            b.type = 'button';
951            b.addEventListener('click', function () {
952                if (busy) { return; }
953                input.value = '';
954                hideSuggest();
955                addUser(s.t);
956                log('chip', { label: s.t, suggest: 1 });
957                showNode(s.go);
958            });
959            suggestBox.appendChild(b);
960        });
961        suggestBox.hidden = false;
962    });
963
964    CHIPS.forEach(function (c, i) {
965        var b = el('button', 'acb-chip acb-chip--c' + (i % 4), c.t); // インデックスの色は部会カラーを順に使う
966        b.type = 'button';
967        b.addEventListener('click', function () {
968            if (busy || leadActive()) { return; }
969            addUser(c.t);
970            log('chip', { label: c.t });
971            showNode(c.go);
972        });
973        chipsBox.appendChild(b);
974    });
975
976    form.addEventListener('submit', function (ev) {
977        ev.preventDefault();
978        if (busy || leadActive()) { return; }
979        var q = input.value.trim();
980        if (!q) { return; }
981        input.value = '';
982        hideSuggest();
983        addUser(q);
984        var r = routeInput(q);
985        log('input', { q: q.slice(0, 200), matched: r.matched });
986        showNode(r.to);
987    });
988
989    /* ===== 終了確認・開閉 ===== */
990    function showConfirm() {
991        confirmBox.hidden = false;
992        confirmBox.querySelector('.acb-confirm__no').focus();
993    }
994    function hideConfirm() {
995        confirmBox.hidden = true;
996        confirmBox.querySelector('.acb-confirm__ask').hidden = false;
997        confirmBox.querySelector('.acb-confirm__fb').hidden = true;
998    }
999    function endChat(feedback) {
1000        log('feedback', { v: feedback });
1001        log('end', {});
1002        flushLog();
1003        hideConfirm();
1004        ++renderSeq;
1005        body.innerHTML = '';
1006        clearState();
1007        setLeadMode(false);
1008        setOpen(false);
1009    }
1010    confirmBox.querySelector('.acb-confirm__no').addEventListener('click', hideConfirm);
1011    confirmBox.querySelector('.acb-confirm__yes').addEventListener('click', function () {
1012        confirmBox.querySelector('.acb-confirm__ask').hidden = true;
1013        confirmBox.querySelector('.acb-confirm__fb').hidden = false;
1014        confirmBox.querySelector('.acb-fb__good').focus();
1015    });
1016    confirmBox.querySelector('.acb-fb__good').addEventListener('click', function () { endChat('good'); });
1017    confirmBox.querySelector('.acb-fb__bad').addEventListener('click', function () { endChat('bad'); });
1018    confirmBox.querySelector('.acb-fb__skip').addEventListener('click', function () { endChat('skip'); });
1019
1020    // 開くボタン: PC は右下のボタン、SP は追尾バーの「チャット」(footer.php の data-ajec-chat-open)
1021    var openers = [launcher].concat(Array.prototype.slice.call(document.querySelectorAll('[data-ajec-chat-open]')));
1022    var opened = false;
1023    var lastOpener = null;
1024
1025    function setOpen(next, from) {
1026        opened = next;
1027        panel.hidden = !opened;
1028        root.classList.toggle('acb--open', opened);
1029        document.documentElement.classList.toggle('acb-lock', opened);
1030        openers.forEach(function (b) { b.setAttribute('aria-expanded', opened ? 'true' : 'false'); });
1031        if (opened) {
1032            lastOpener = from || null;
1033            // 初回(または保存された会話が空)なら挨拶から始める。2回目以降は続きから
1034            if (!state.started || !body.querySelector('.acb-turn, .acb-lead-ui')) {
1035                state.started = true;
1036                persist();
1037                showNode('n0');
1038            } else {
1039                scrollBottom(); // 閉じている間に復元した会話は、開いたときに最新の位置を見せる
1040            }
1041            log('open', {});
1042            // 入力欄へフォーカスするのはマウス環境だけ(タッチ端末でキーボードを勝手に出さない)。
1043            // 問い合わせの入力中は入力欄が隠れているのでパネル自体へ
1044            if (FINE_POINTER && !leadActive()) { input.focus(); } else { panel.focus({ preventScroll: true }); }
1045        } else {
1046            hideSuggest();
1047            log('close', {});
1048            flushLog();
1049            if (lastOpener && document.body.contains(lastOpener)) { lastOpener.focu
1049s({ preventScroll: true }); }
1050        }
1051    }
1052
1053    openers.forEach(function (b) {
1054        b.addEventListener('click', function () { setOpen(!opened, b); });
1055    });
1056    root.querySelector('.acb-head__min').addEventListener('click', function () { setOpen(false); });
1057    root.querySelector('.acb-head__close').addEventListener('click', function () {
1058        if (state.msgs.length) { showConfirm(); } else { setOpen(false); }
1059    });
1060    document.addEventListener('keydown', function (ev) {
1061        if (ev.key !== 'Escape' || !opened) { return; }
1062        if (!confirmBox.hidden) { hideConfirm(); return; }
1063        setOpen(false);
1064    });
1065
1066    /* ===== 復元(ページを移っても会話を続ける) ===== */
1067    restoreState();
1068    state.msgs.forEach(function (m) {
1069        if (m.k === 'bot' || m.k === 'user') { addTurn(m.k, m.v, { norecord: true, ts: m.ts }); }
1070    });
1071    if (leadActive()) {
1072        setLeadMode(true);
1073        if (state.lead.step === 'type') { renderLeadTypes(); } else { renderLeadForm(); }
1074    } else if (state.choices) {
1075        renderChoices(state.choices, { norecord: true });
1076    }
1077
1078    document.body.appendChild(root);
1079})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.