目录
サービスページを読んだユーザーが「この内容で相談したい」と思ったとき、単に問い合わせフォームへ送るだけでは、少し文脈が落ちます。
ユーザーはフォームでサービス種別を選び直し、件名も自分で書き直す必要があります。受信側も、本文を読むまで「Web制作の相談なのか」「業務システムなのか」「Aceserverなのか」を判断しにくくなります。
Acecoreのサイトでは、サービスCTAから問い合わせフォームへ相談対象を引き継ぐPR でこの導線を改善しました。この記事では、Astroでの実装記録としてだけでなく、他のWebサイトでも使える導線設計として整理します。
目的はフォーム入力を減らすことではない
この実装の目的は、フォーム項目を自動入力して楽に見せることだけではありません。
本質は、サービスページで生まれた文脈を、問い合わせフォームと受信オペレーションへ正しく渡すことです。
| 観点 | 改善したいこと |
|---|---|
| ユーザー | 読んでいたサービスをもう一度選ぶ手間を減らす |
| フォーム | 問い合わせ種別と件名を相談内容に合わせて初期化する |
| 受信側 | 問い合わせ種別だけで相談対象を分類しやすくする |
| 計測 | どのサービスCTAから相談が始まったか追いやすくする |
| 多言語導線 | localeに合う問い合わせURLへ送る |
見た目としては小さなミニCTAですが、設計対象はCTA、URL、フォーム、翻訳、計測、受信運用までまたがります。
CTAコンポーネントに責務を切り出す
各サービスセクションの末尾に「このサービスについて相談する」CTAを置きます。
最初に避けたいのは、サービスページの各セクションに同じリンク生成とGA属性をベタ書きすることです。サービスが7件あるなら、同じ書き方が7回出ます。文言やURL仕様を変えたくなったときに漏れやすくなります。
そこで、CTAと関連事例リンクをまとめる ServiceSectionActions を作りました。
---
import Icon from './Icon.astro'
import type { ServiceWorkLink } from '../data/works'
import { t, getLocalizedUrl, type Locale } from '../i18n'
interface Props {
locale: Locale
gaLabel: string
gaLocation: string
serviceKey: string
workLink?: ServiceWorkLink
}
const { locale, gaLabel, gaLocation, serviceKey, workLink } = Astro.props
const u = (path: string) => getLocalizedUrl(path, locale)
const contactUrl = `${u('/contact/')}?category=service&service=${encodeURIComponent(serviceKey)}#contact-form`
---
<a
href={contactUrl}
class="ac-btn-outline gap-2 text-sm sm:w-auto"
data-ga-event="cta_click"
data-ga-label={gaLabel}
data-ga-location={gaLocation}
data-ga-destination={contactUrl}
>
<Icon name="message-circle" class="text-sm" />
{t(locale, 'pages.services.miniCta')}
</a>
このコンポーネントの責務は4つです。
- localeに合う問い合わせURLを生成する
- service keyをURLパラメータへ入れる
- GA計測用のlabelとlocationを持たせる
- 既存の関連事例リンクを同じアクション領域に残す
CTAはユーザーの行動点なので、UIだけでなく計測点でもあります。data-ga-label と data-ga-location は、あとから「どのサービスから相談が始まったか」を見るために残しています。
URLパラメータをフォームとの契約にする
CTAから問い合わせフォームへ渡す値は、URLパラメータにしました。
/contact/?category=service&service=web#contact-form
ここで重要なのは、URLに入れる値を「表示文言」にしないことです。
Webサイト制作・運用について のような表示文言は、翻訳、表記揺れ、将来の名称変更の影響を受けます。URLには web や system-development のような短いservice keyだけを入れます。
| パラメータ | 役割 |
|---|---|
category | サービス相談として処理する入口を示す |
service | 対象サービスを表す安定したkey |
| hash | フォーム位置へスクロールするために使う |
URLパラメータはユーザーが編集できるものです。だからこそ、フォーム側ではURL値をそのまま送信値にせず、既存のoptionへマッピングします。
フォーム側で分類表を持つ
問い合わせフォーム側では、サービス別の分類を配列で持ちます。
const serviceCategoryOptions = [
{
key: 'system-development',
value: '業務システム・アプリ開発について',
label: t(locale, 'pages.contact.formCategoryServiceSystemDev'),
subject: t(locale, 'pages.services.systemDev.title'),
},
{
key: 'web',
value: 'Webサイト制作・運用について',
label: t(locale, 'pages.contact.formCategoryServiceWeb'),
subject: t(locale, 'pages.services.web.title'),
},
]
key、value、label、subject はそれぞれ役割が違います。
| フィールド | 役割 |
|---|---|
key | URLパラメータから探すための安定した識別子 |
value | フォーム送信時に受信側へ届く問い合わせ種別 |
label | 画面に表示する翻訳済みの選択肢 |
subject | 件名の初期入力に使うサービス名 |
多言語サイトの場合、label はlocaleに合わせて翻訳します。一方で、value は受信側の分類に使うため、日本語の安定した値に寄せました。
これはプロダクトによって判断が分かれるところです。CRMや外部フォームが多言語分類を持てるなら、valueもlocale別にできます。今回のように受信側の運用をシンプルにしたい場合は、表示ラベルと受信値を分けるほうが扱いやすくなります。
optionにdata属性を持たせる
フォームのselectには、サービス別optionを出力します。
<select id="category" name="category" required>
<option value="" disabled selected>
{t(locale, 'pages.contact.formCategoryPlaceholder')}
</option>
<option value="サービス全般について">
{t(locale, 'pages.contact.formCategoryService')}
</option>
{
serviceCategoryOptions.map((option) => (
<option
value={option.value}
data-service-key={option.key}
data-service-subject={option.subject}
>
{option.label}
</option>
))
}
</select>
data-service-key はURLの service と照合するために使います。data-service-subject は件名を作るために使います。
ここでもURL値をそのまま category.value に入れないのがポイントです。必ずselect内のoptionから選ぶことで、未知のservice keyや不正な値を受信値に混ぜないようにしています。
クライアント側でprefillする
フォームの初期化は、ページ読み込み後の小さなスクリプトで行います。
function initContactServicePrefill() {
const form = document.getElementById('contact-form')
if (!form || form.dataset.servicePrefillInitialized === 'true') return
form.dataset.servicePrefillInitialized = 'true'
const url = new URL(window.location.href)
const requestedCategory = url.searchParams.get('category')
const requestedService = url.searchParams.get('service') || ''
const category = document.getElementById('category')
const subject = document.getElementById('subject')
if (
requestedCategory === 'service' &&
category instanceof HTMLSelectElement
) {
const serviceOption = Array.from(category.options).find((option) => {
return option.dataset.serviceKey === requestedService
})
category.value = serviceOption?.value || 'サービス全般について'
category.dispatchEvent(new Event('input', { bubbles: true }))
category.dispatchEvent(new Event('change', { bubbles: true }))
if (
serviceOption &&
subject instanceof HTMLInputElement &&
!subject.value.trim()
) {
const template = form.dataset.serviceSubjectTemplate || '{service}'
const serviceName =
serviceOption.dataset.serviceSubject ||
serviceOption.textContent?.trim() ||
''
subject.value = template.replace('{service}', serviceName)
}
}
}
実装上のポイントは次の4つです。
- 二重初期化を避けるため
data-service-prefill-initializedを見る category=serviceのときだけ処理する- 未知のservice keyは「サービス全般について」へフォールバックする
- 件名は空欄のときだけ初期入力する
最後の「件名は空欄のときだけ」が重要です。ユーザーが戻る操作やブラウザ補完で件名を持っている場合、勝手に上書きすると体験が悪くなります。
AstroのView Transitionsやクライアントナビゲーションがある場合は、通常の初期ロードだけでなく astro:page-load でも初期化します。
document.addEventListener('astro:page-load', initContactServicePrefill)
initContactServicePrefill()
hashでフォーム位置へ移動する
CTAのURLには #contact-form を付けています。
/contact/?category=service&service=web#contact-form
問い合わせページにはFAQ、LINE、説明文、フォーム以外の連絡手段などがあるため、サービスCTAから来たユーザーはフォーム位置へ直接移動したほうが自然です。
ただし、フォーム側で初期化を行う場合、スクロールタイミングには少し注意します。要素が描画された後にスクロールしたいので、requestAnimationFrame を使っています。
if (window.location.hash === '#contact-form') {
window.requestAnimationFrame(() => {
form.scrollIntoView({ block: 'start' })
})
}
フォーム位置への移動は小さな挙動ですが、CTAの意図とフォームの表示位置がずれるとユーザーが迷います。導線設計では、URL、初期選択、スクロール位置まで一体で見ます。
hidden項目を増やさない判断
今回、相談対象サービス のhidden項目は追加しませんでした。
理由は、問い合わせ種別だけで対象サービスを判別できるようにしたかったからです。
フォーム項目を増やすと、次の確認点も増えます。
- 通知メールに出すか
- 管理画面やスプレッドシートに列を増やすか
- 既存の自動返信テンプレートへ影響するか
- CRM連携やWebhookで扱うか
- 多言語表示名と受信値をどう分けるか
必要な情報が既存項目で表せるなら、項目を増やさないほうが運用は安定します。今回は お問い合わせ種別 を「サービス全般」とサービス別に分けるだけで、受信側が判別できる形にしました。
もちろん、複数サービスを同時選択したい、広告キャンペーンIDも保存したい、CRMで別フィールドにしたい、といった要件がある場合はhidden項目を追加する判断もあります。
多言語サイトでの考え方
多言語サイトでこの導線を作るときは、3つの値を分けて考えると混乱しません。
| 種類 | 例 | locale依存 |
|---|---|---|
| URL key | web, server, aceserver | しない |
| 表示ラベル | About Website Design など | する |
| 受信値 | Webサイト制作・運用について | 運用次第 |
URL keyは翻訳しないほうが安定します。リンクを共有したり、分析したり、フォーム側で照合したりするためです。
表示ラベルは必ず翻訳します。ユーザーがフォームで見る文言だからです。
受信値は、運用に合わせます。今回は日本語の安定値に寄せました。多言語対応の表示と、受信後の社内運用は別物として設計すると、フォームが扱いやすくなります。
翻訳フロー自体は、Sveltia CMSで多言語ブログを運用する方法 でも紹介しています。
生成HTMLで確認する
この種の実装は、コンポーネントだけ見ても不十分です。build後のHTMLで、実際にリンクとoptionが出力されているかを確認します。
今回確認した観点は次の通りです。
/services/にサービス別CTAが7件出ている- 各CTAが
?category=service&service=...#contact-formを持っている /contact/にdata-service-key付きoptionが7件出ている- 「サービス全般について」とサービス別種別が出ている
相談対象サービスのhidden項目が出ていない
たとえば、生成HTMLに対して rg で確認できます。
rg -n "category=service&service=.*#contact-form" dist\services\index.html
rg -n "data-service-key" dist\contact\index.html
rg -n "相談対象サービス" dist\contact\index.html
最後の確認は、出てはいけないものが出ていないことを見るためです。フォーム改修では、追加したものだけでなく、追加しないと決めたものも確認対象にします。
AIチャットとの役割分担
この導線は、問い合わせAIチャットの技術設計 と相性があります。ただし、役割は違います。
| 導線 | 得意なこと |
|---|---|
| AIチャット | どのサービスに相談すべきか会話で整理する |
| サービスCTA | 読んでいるサービスの文脈をフォームへ渡す |
| フォーム | 正式な相談内容を受け取り、記録を残す |
AIチャットは、ユーザーがまだ迷っている段階に強い導線です。一方で、サービスページを読み終えて「このサービスについて相談する」と決めたユーザーには、会話を挟まずにフォームへ送るほうが自然です。
導線を増やすときは、すべてを同じ役割にしないことが大切です。ユーザーの状態に合わせて、会話、CTA、フォームを使い分けます。
まとめ
サービスページから問い合わせフォームへ文脈を引き継ぐ実装は、見た目以上に効果があります。
今回の設計で重要だったのは、次の点です。
- CTAをコンポーネント化してURL生成と計測属性をまとめる
- URLには表示文言ではなく安定したservice keyを入れる
- フォーム側でservice keyをoptionへマッピングする
- 受信値、表示ラベル、件名用サービス名を分ける
- 未知のservice keyはサービス全般へフォールバックする
- 件名は空欄のときだけ初期入力する
- hidden項目を増やさず、問い合わせ種別で分類できるようにする
- build後の生成HTMLでリンク数、option数、不要項目の不在を確認する
問い合わせフォーム改善は、入力欄を減らすだけではありません。ユーザーが読んでいた文脈を受信側まで失わずに渡すことが、実際の相談対応を楽にします。
サービスCTAからフォームへ文脈を渡す流れ
Service
各サービスセクションのCTAに service key を持たせる。
URL
/contact/?category=service&service=web#contact-form のようにURL契約で渡す。
Form
フォーム側で該当する問い合わせ種別と件名を初期入力する。
Ops
受信側は問い合わせ種別だけでサービス文脈を判別できる。
ただフォームへ送る場合
- ユーザーがフォームで同じサービス名を選び直す
- 件名が空欄になり、何の相談か伝わりにくい
- 受信側が本文を読まないと対象サービスを判別しにくい
- サービス別CTAの効果測定が曖昧になる
文脈を引き継ぐ場合
- CTAのservice keyから問い合わせ種別を初期選択できる
- 件名にサービス名を入れて相談内容を整理できる
- 受信側は問い合わせ種別を見れば分類できる
- GA labelとURLパラメータでCTA別に確認しやすい
- 已完成: URLパラメータは短く安定したservice keyだけにする
- 已完成: フォームの受信値はユーザー表示文言ではなく運用上安定した値にする
- 已完成: 未知のservice keyはサービス全般へフォールバックする
- 已完成: 件名は空欄のときだけ初期入力する
- 已完成: hidden項目を増やす前に既存の問い合わせ種別で分類できるか確認する
- 已完成: locale別の問い合わせURLをサーバー側で生成する
- 已完成: CTAにはGA labelとlocationを付けて効果測定できるようにする
- 已完成: build後の生成HTMLでCTA数、option数、hidden項目の有無を確認する
hidden項目で相談対象サービスを送らないのはなぜですか?
URLパラメータは改ざんされても大丈夫ですか?
多言語サイトではどう扱いますか?
评论
Gui
Acecore 代表。从业务课题梳理到设计、导入和上线后的改进,统筹推进业务系统、Web、数据库/基础设施、质量保证以及 AI 应用。 以 C#/.NET 的实际开发能力为基础,同时理解 PHP/JavaScript、SQL Server/PostgreSQL/MySQL、Linux/Windows Server,将需求整理、技术选型、质量标准和基于 GitHub 的开发运营作为整体流程来设计。 将生成式 AI 用于开发、验证和信息整理等业务流程,作为帮助小团队更快、更可靠交付成果的实务基础。