@kagayoi/support-extension 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -0
- package/bin/kagayoi-support-sync.mjs +95 -0
- package/dist/kagayoi-support-footer.css +45 -0
- package/dist/kagayoi-support-footer.js +217 -0
- package/dist/kagayoi-support-form.css +103 -0
- package/dist/kagayoi-support-popup.css +85 -0
- package/dist/kagayoi-support-popup.js +647 -0
- package/package.json +38 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
"use strict"
|
|
3
|
+
|
|
4
|
+
const DEFAULT_API_BASE = "https://support.kagayoi.com"
|
|
5
|
+
const SESSION_KEY = "kagayoi-support-session"
|
|
6
|
+
const API_TIMEOUT_MS = 15_000
|
|
7
|
+
const FORM_STYLESHEET_URL = bundledStylesheetUrl("kagayoi-support-form.css")
|
|
8
|
+
const POPUP_STYLESHEET_URL = bundledStylesheetUrl("kagayoi-support-popup.css")
|
|
9
|
+
const FIREFOX_OPTIONAL_CONTACT_DATA_PERMISSIONS = ["personalCommunications"]
|
|
10
|
+
const CHANNELS = new Set(["web", "desktop", "extension", "other"])
|
|
11
|
+
const STORAGE_SCOPES = new Set(["session", "local"])
|
|
12
|
+
const CATEGORIES = [
|
|
13
|
+
["question", "使い方・ご質問"],
|
|
14
|
+
["bug", "不具合のご報告"],
|
|
15
|
+
["feature", "機能のご要望"],
|
|
16
|
+
["billing", "料金・ご依頼"],
|
|
17
|
+
["other", "その他"],
|
|
18
|
+
]
|
|
19
|
+
const ERROR_LABELS = {
|
|
20
|
+
"invalid email": "メールアドレスを確認してください。",
|
|
21
|
+
"too many requests": "短時間に送信が集中しています。少し時間を置いてからお試しください。",
|
|
22
|
+
"email unavailable": "確認メールを送信できませんでした。時間を置いてからお試しください。",
|
|
23
|
+
"invalid code": "確認コードが正しくありません。",
|
|
24
|
+
"code expired or locked": "確認コードの期限が切れたか、入力回数の上限に達しました。新しいコードを取得してください。",
|
|
25
|
+
"code already used": "この確認コードは使用済みです。新しいコードを取得してください。",
|
|
26
|
+
"authentication required": "認証の有効期限が切れました。確認コードを取得し直してください。",
|
|
27
|
+
"invalid session": "認証の有効期限が切れました。確認コードを取得し直してください。",
|
|
28
|
+
"invalid ticket": "入力内容を確認してください。",
|
|
29
|
+
"invalid ticket metadata": "入力内容が長すぎます。",
|
|
30
|
+
"unknown product": "お問い合わせ先を確認できませんでした。",
|
|
31
|
+
"request too large": "お問い合わせ内容が長すぎます。",
|
|
32
|
+
"origin not allowed": "このサイトからは現在送信できません。",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bundledStylesheetUrl(fileName) {
|
|
36
|
+
const api = typeof browser !== "undefined" ? browser : typeof chrome !== "undefined" ? chrome : null
|
|
37
|
+
if (api?.runtime?.getURL) return api.runtime.getURL(`src/shared/${fileName}`)
|
|
38
|
+
|
|
39
|
+
const script = Array.from(document.scripts).find(({ src }) =>
|
|
40
|
+
/(?:^|\/)(?:contact-form|kagayoi-support-popup)\.js(?:[?#]|$)/.test(src),
|
|
41
|
+
)
|
|
42
|
+
return new URL(fileName, script?.src || document.baseURI).href
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// shadow DOM の中身は innerHTML 代入で組まない。このファイルは Chrome 拡張へ同梱する正本で、
|
|
46
|
+
// AMO の静的解析が innerHTML 代入を UNSAFE_VAR_ASSIGNMENT として弾き、拡張側リポジトリにも
|
|
47
|
+
// 「innerHTML 不使用」の契約があるため。テンプレートは静的文字列のみで、利用者入力は通さない。
|
|
48
|
+
function replaceShadowContent(shadowRoot, stylesheetUrl, markup) {
|
|
49
|
+
const parsed = new DOMParser().parseFromString(markup, "text/html")
|
|
50
|
+
const stylesheet = document.createElement("link")
|
|
51
|
+
stylesheet.rel = "stylesheet"
|
|
52
|
+
stylesheet.href = stylesheetUrl
|
|
53
|
+
// MV3 の style-src 'self' では、style要素もConstructable StylesheetのCSS文字列もinline扱いになる。
|
|
54
|
+
// 同梱した外部CSSだけを読み込み、Shadow DOMへinline styleを一度も入れない。
|
|
55
|
+
shadowRoot.replaceChildren(stylesheet, ...parsed.body.childNodes)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let instanceCount = 0
|
|
59
|
+
|
|
60
|
+
class KagayoiContactForm extends HTMLElement {
|
|
61
|
+
constructor() {
|
|
62
|
+
super()
|
|
63
|
+
this.attachShadow({ mode: "open" })
|
|
64
|
+
this.instanceId = `kagayoi-support-${++instanceCount}`
|
|
65
|
+
this.codeRequestedFor = ""
|
|
66
|
+
this.ticketIdempotencyKey = ""
|
|
67
|
+
this.busy = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
connectedCallback() {
|
|
71
|
+
if (this.shadowRoot.childElementCount) return
|
|
72
|
+
this.productId = this.getAttribute("product-id")?.trim() ?? ""
|
|
73
|
+
this.productName = this.getAttribute("product-name")?.trim() || "このサイト"
|
|
74
|
+
this.apiBase = (this.getAttribute("api-base")?.trim() || DEFAULT_API_BASE).replace(/\/$/, "")
|
|
75
|
+
const requestedChannel = this.getAttribute("channel")?.trim().toLowerCase() || "web"
|
|
76
|
+
const requestedStorage = this.getAttribute("storage")?.trim().toLowerCase() || "session"
|
|
77
|
+
this.channel = CHANNELS.has(requestedChannel) ? requestedChannel : "web"
|
|
78
|
+
this.storageScope = STORAGE_SCOPES.has(requestedStorage) ? requestedStorage : "session"
|
|
79
|
+
this.appVersion = this.optionalAttribute("app-version", 100)
|
|
80
|
+
this.osVersion = this.optionalAttribute("os-version", 200)
|
|
81
|
+
this.locale = this.optionalAttribute("locale", 40) || navigator.language?.slice(0, 40) || null
|
|
82
|
+
this.diagnostics = this.optionalAttribute("diagnostics", 20000)
|
|
83
|
+
this.render()
|
|
84
|
+
this.bindEvents()
|
|
85
|
+
this.syncAuthState()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
render() {
|
|
89
|
+
const id = this.instanceId
|
|
90
|
+
replaceShadowContent(this.shadowRoot, FORM_STYLESHEET_URL, `
|
|
91
|
+
<form class="panel" novalidate aria-label="お問い合わせフォーム">
|
|
92
|
+
<p class="intro"><strong class="product-name"></strong>について、不具合・ご質問・ご要望・ご依頼を送信できます。返信先の確認のため、初回はメールで届く6桁のコードを入力してください。</p>
|
|
93
|
+
<div class="grid">
|
|
94
|
+
<div class="field">
|
|
95
|
+
<label for="${id}-name">お名前 <span class="optional">(任意)</span></label>
|
|
96
|
+
<input id="${id}-name" name="customerName" type="text" autocomplete="name" maxlength="100">
|
|
97
|
+
</div>
|
|
98
|
+
<div class="field">
|
|
99
|
+
<label for="${id}-email">メールアドレス</label>
|
|
100
|
+
<input id="${id}-email" name="email" type="email" autocomplete="email" inputmode="email" maxlength="254" required>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="field field--full">
|
|
103
|
+
<label for="${id}-category">お問い合わせ種別</label>
|
|
104
|
+
<select id="${id}-category" name="category" required>
|
|
105
|
+
${CATEGORIES.map(([value, label]) => `<option value="${value}">${label}</option>`).join("")}
|
|
106
|
+
</select>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="field field--full">
|
|
109
|
+
<label for="${id}-subject">件名</label>
|
|
110
|
+
<input id="${id}-subject" name="subject" type="text" maxlength="160" required>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="field field--full">
|
|
113
|
+
<label for="${id}-description">お問い合わせ内容</label>
|
|
114
|
+
<textarea id="${id}-description" name="description" maxlength="10000" required></textarea>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="verification" hidden>
|
|
117
|
+
<div class="field">
|
|
118
|
+
<label for="${id}-code">6桁の確認コード</label>
|
|
119
|
+
<input id="${id}-code" name="code" type="text" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" minlength="6" maxlength="6">
|
|
120
|
+
</div>
|
|
121
|
+
<p>コードは10分間有効です。届かない場合は迷惑メールフォルダーもご確認ください。<br><button class="resend" type="button">確認コードを再送する</button></p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="actions">
|
|
125
|
+
<button class="submit" type="submit">確認コードを送信</button>
|
|
126
|
+
<p class="auth-note" hidden></p>
|
|
127
|
+
</div>
|
|
128
|
+
<p class="status" role="status" aria-live="polite"></p>
|
|
129
|
+
</form>
|
|
130
|
+
<section class="success" hidden tabindex="-1" aria-live="polite">
|
|
131
|
+
<h2>お問い合わせを受け付けました</h2>
|
|
132
|
+
<p>受付番号を控えてください。</p>
|
|
133
|
+
<p class="reference"></p>
|
|
134
|
+
<p>同じメールアドレスでログインすると、返信と対応状況を <a href="https://support.kagayoi.com/tickets" target="_blank" rel="noopener">Kagayoi Support</a> で確認できます。</p>
|
|
135
|
+
<button class="again" type="button">別のお問い合わせを送る</button>
|
|
136
|
+
</section>
|
|
137
|
+
`)
|
|
138
|
+
this.form = this.shadowRoot.querySelector("form")
|
|
139
|
+
this.success = this.shadowRoot.querySelector(".success")
|
|
140
|
+
this.email = this.form.elements.email
|
|
141
|
+
this.code = this.form.elements.code
|
|
142
|
+
this.verification = this.shadowRoot.querySelector(".verification")
|
|
143
|
+
this.submit = this.shadowRoot.querySelector(".submit")
|
|
144
|
+
this.resend = this.shadowRoot.querySelector(".resend")
|
|
145
|
+
this.status = this.shadowRoot.querySelector(".status")
|
|
146
|
+
this.authNote = this.shadowRoot.querySelector(".auth-note")
|
|
147
|
+
this.shadowRoot.querySelector(".product-name").textContent = this.productName
|
|
148
|
+
this.form.setAttribute("aria-label", `${this.productName}へのお問い合わせフォーム`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
bindEvents() {
|
|
152
|
+
this.form.addEventListener("submit", (event) => {
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
void this.handleSubmit()
|
|
155
|
+
})
|
|
156
|
+
this.email.addEventListener("input", () => {
|
|
157
|
+
if (this.codeRequestedFor && this.normalizedEmail() !== this.codeRequestedFor) this.resetVerification()
|
|
158
|
+
this.syncAuthState()
|
|
159
|
+
})
|
|
160
|
+
this.resend.addEventListener("click", () => void this.requestCode())
|
|
161
|
+
this.shadowRoot.querySelector(".again").addEventListener("click", () => this.resetForm())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async handleSubmit() {
|
|
165
|
+
if (this.busy || !this.productId) return
|
|
166
|
+
if (!this.validateTicketFields()) return
|
|
167
|
+
if (!await this.ensureDataCollectionConsent()) return
|
|
168
|
+
const session = this.sessionForCurrentEmail()
|
|
169
|
+
if (session) {
|
|
170
|
+
await this.createTicket(session.accessToken)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (this.codeRequestedFor === this.normalizedEmail()) {
|
|
174
|
+
if (!this.code.checkValidity()) {
|
|
175
|
+
this.code.reportValidity()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
await this.verifyAndCreate()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
await this.requestCode(true)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
validateTicketFields() {
|
|
185
|
+
for (const control of [this.email, this.form.elements.category, this.form.elements.subject, this.form.elements.description]) {
|
|
186
|
+
if (!control.checkValidity()) {
|
|
187
|
+
control.reportValidity()
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async requestCode(consentChecked = false) {
|
|
195
|
+
if (this.busy || !this.email.checkValidity()) {
|
|
196
|
+
if (!this.email.checkValidity()) this.email.reportValidity()
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (!consentChecked && !await this.ensureDataCollectionConsent()) return
|
|
200
|
+
const email = this.normalizedEmail()
|
|
201
|
+
await this.runBusy(async () => {
|
|
202
|
+
const data = await this.api("/api/auth/request", { method: "POST", body: { email } })
|
|
203
|
+
this.codeRequestedFor = email
|
|
204
|
+
this.verification.hidden = false
|
|
205
|
+
this.code.required = true
|
|
206
|
+
if (data.devCode && /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(this.apiBase)) this.code.value = data.devCode
|
|
207
|
+
this.submit.textContent = "認証して問い合わせを送信"
|
|
208
|
+
this.setStatus(`${email} へ確認コードを送りました。`, "success")
|
|
209
|
+
this.code.focus()
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async verifyAndCreate() {
|
|
214
|
+
const email = this.normalizedEmail()
|
|
215
|
+
await this.runBusy(async () => {
|
|
216
|
+
const session = await this.api("/api/auth/verify", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
body: { email, code: this.code.value.trim() },
|
|
219
|
+
})
|
|
220
|
+
this.storeSession(session)
|
|
221
|
+
await this.createTicket(session.accessToken, false)
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async createTicket(accessToken, manageBusy = true) {
|
|
226
|
+
const action = async () => {
|
|
227
|
+
this.ticketIdempotencyKey ||= crypto.randomUUID()
|
|
228
|
+
const result = await this.api("/api/tickets", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
token: accessToken,
|
|
231
|
+
idempotencyKey: this.ticketIdempotencyKey,
|
|
232
|
+
body: {
|
|
233
|
+
productId: this.productId,
|
|
234
|
+
customerName: this.form.elements.customerName.value.trim() || null,
|
|
235
|
+
category: this.form.elements.category.value,
|
|
236
|
+
subject: this.form.elements.subject.value.trim(),
|
|
237
|
+
description: this.form.elements.description.value.trim(),
|
|
238
|
+
channel: this.channel,
|
|
239
|
+
appVersion: this.appVersion,
|
|
240
|
+
osVersion: this.osVersion,
|
|
241
|
+
locale: this.locale,
|
|
242
|
+
diagnostics: this.diagnostics,
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
this.ticketIdempotencyKey = ""
|
|
246
|
+
this.showSuccess(result.ticket.reference)
|
|
247
|
+
}
|
|
248
|
+
if (manageBusy) await this.runBusy(action)
|
|
249
|
+
else await action()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async ensureDataCollectionConsent() {
|
|
253
|
+
if (!this.hasAttribute("firefox-data-consent")) return true
|
|
254
|
+
const api = globalThis.browser ?? globalThis.chrome
|
|
255
|
+
let extensionOrigin = ""
|
|
256
|
+
try {
|
|
257
|
+
extensionOrigin = api?.runtime?.getURL?.("") || ""
|
|
258
|
+
} catch {}
|
|
259
|
+
if (!extensionOrigin.startsWith("moz-extension://")) return true
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// submit / resend の user-activated handler 内で最初の非同期 API として呼ぶ。
|
|
263
|
+
// 既に許可済みなら Firefox はプロンプトなしで true を返す。
|
|
264
|
+
const granted = await api.permissions.request({
|
|
265
|
+
data_collection: FIREFOX_OPTIONAL_CONTACT_DATA_PERMISSIONS,
|
|
266
|
+
})
|
|
267
|
+
if (granted) return true
|
|
268
|
+
this.setStatus("お問い合わせ情報の送信には Firefox の許可が必要です。", "error")
|
|
269
|
+
} catch {
|
|
270
|
+
this.setStatus("Firefox のデータ送信許可を確認できませんでした。", "error")
|
|
271
|
+
}
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async api(path, options) {
|
|
276
|
+
const headers = { "Content-Type": "application/json" }
|
|
277
|
+
if (options.token) headers.Authorization = `Bearer ${options.token}`
|
|
278
|
+
if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey
|
|
279
|
+
let response
|
|
280
|
+
try {
|
|
281
|
+
response = await fetch(`${this.apiBase}${path}`, {
|
|
282
|
+
method: options.method,
|
|
283
|
+
credentials: "omit",
|
|
284
|
+
headers,
|
|
285
|
+
body: JSON.stringify(options.body),
|
|
286
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
287
|
+
})
|
|
288
|
+
} catch {
|
|
289
|
+
throw new Error("network")
|
|
290
|
+
}
|
|
291
|
+
const data = await response.json().catch(() => ({}))
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
const error = new Error(data.error || "request failed")
|
|
294
|
+
error.status = response.status
|
|
295
|
+
throw error
|
|
296
|
+
}
|
|
297
|
+
return data
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async runBusy(action) {
|
|
301
|
+
this.busy = true
|
|
302
|
+
this.submit.disabled = true
|
|
303
|
+
this.resend.disabled = true
|
|
304
|
+
this.setStatus("送信しています…")
|
|
305
|
+
try {
|
|
306
|
+
await action()
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error.status === 401) {
|
|
309
|
+
this.clearSession()
|
|
310
|
+
this.resetVerification()
|
|
311
|
+
}
|
|
312
|
+
this.setStatus(this.errorLabel(error), "error")
|
|
313
|
+
} finally {
|
|
314
|
+
this.busy = false
|
|
315
|
+
this.submit.disabled = false
|
|
316
|
+
this.resend.disabled = false
|
|
317
|
+
this.syncAuthState()
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
showSuccess(reference) {
|
|
322
|
+
this.shadowRoot.querySelector(".reference").textContent = reference
|
|
323
|
+
this.form.hidden = true
|
|
324
|
+
this.success.hidden = false
|
|
325
|
+
this.success.focus?.()
|
|
326
|
+
this.dispatchEvent(new CustomEvent("kagayoi-support-submitted", {
|
|
327
|
+
bubbles: true,
|
|
328
|
+
composed: true,
|
|
329
|
+
detail: { reference },
|
|
330
|
+
}))
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
resetForm() {
|
|
334
|
+
const email = this.normalizedEmail()
|
|
335
|
+
this.form.reset()
|
|
336
|
+
this.email.value = email
|
|
337
|
+
this.resetVerification()
|
|
338
|
+
this.success.hidden = true
|
|
339
|
+
this.form.hidden = false
|
|
340
|
+
this.setStatus("")
|
|
341
|
+
this.syncAuthState()
|
|
342
|
+
this.form.elements.subject.focus()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
resetVerification() {
|
|
346
|
+
this.codeRequestedFor = ""
|
|
347
|
+
this.code.value = ""
|
|
348
|
+
this.code.required = false
|
|
349
|
+
this.verification.hidden = true
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
syncAuthState() {
|
|
353
|
+
if (this.codeRequestedFor) return
|
|
354
|
+
const session = this.sessionForCurrentEmail()
|
|
355
|
+
this.submit.textContent = session ? "問い合わせを送信" : "確認コードを送信"
|
|
356
|
+
this.authNote.hidden = !session
|
|
357
|
+
this.authNote.textContent = session ? `${session.email} は認証済みです。` : ""
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
normalizedEmail() {
|
|
361
|
+
return this.email.value.trim().toLowerCase()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
sessionForCurrentEmail() {
|
|
365
|
+
const session = this.readSession()
|
|
366
|
+
return session?.email === this.normalizedEmail() ? session : null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
readSession() {
|
|
370
|
+
try {
|
|
371
|
+
const session = JSON.parse(this.storageArea()?.getItem(SESSION_KEY) || "null")
|
|
372
|
+
if (!session?.accessToken || !session.email || Number(session.expiresAt) <= Math.floor(Date.now() / 1000)) {
|
|
373
|
+
this.clearSession()
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
return session
|
|
377
|
+
} catch {
|
|
378
|
+
this.clearSession()
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
storeSession(session) {
|
|
384
|
+
try {
|
|
385
|
+
this.storageArea()?.setItem(SESSION_KEY, JSON.stringify({
|
|
386
|
+
accessToken: session.accessToken,
|
|
387
|
+
expiresAt: session.expiresAt,
|
|
388
|
+
email: String(session.email).toLowerCase(),
|
|
389
|
+
}))
|
|
390
|
+
} catch {
|
|
391
|
+
// Storage can be unavailable in hardened browsers. The current submission can still continue.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
clearSession() {
|
|
396
|
+
try {
|
|
397
|
+
this.storageArea()?.removeItem(SESSION_KEY)
|
|
398
|
+
} catch {
|
|
399
|
+
// Nothing else is required when browser storage is unavailable.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
setStatus(message, kind = "") {
|
|
404
|
+
this.status.textContent = message
|
|
405
|
+
this.status.dataset.kind = kind
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
optionalAttribute(name, maxLength) {
|
|
409
|
+
const value = this.getAttribute(name)?.trim()
|
|
410
|
+
return value ? value.slice(0, maxLength) : null
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
storageArea() {
|
|
414
|
+
try {
|
|
415
|
+
return this.storageScope === "local" ? window.localStorage : window.sessionStorage
|
|
416
|
+
} catch {
|
|
417
|
+
return null
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
errorLabel(error) {
|
|
422
|
+
if (error.message === "network") return "通信できませんでした。接続を確認して、もう一度お試しください。"
|
|
423
|
+
return ERROR_LABELS[error.message] || "送信できませんでした。時間を置いてからお試しください。"
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let popupCount = 0
|
|
428
|
+
let documentScrollLockCount = 0
|
|
429
|
+
let documentScrollRestore = []
|
|
430
|
+
let documentScrollPosition = { left: 0, top: 0 }
|
|
431
|
+
|
|
432
|
+
function rememberInlineStyles(element, properties) {
|
|
433
|
+
return {
|
|
434
|
+
element,
|
|
435
|
+
properties: properties.map((property) => ({
|
|
436
|
+
property,
|
|
437
|
+
value: element.style.getPropertyValue(property),
|
|
438
|
+
priority: element.style.getPropertyPriority(property),
|
|
439
|
+
})),
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function applyLockedStyles(element, styles) {
|
|
444
|
+
for (const [property, value] of Object.entries(styles)) {
|
|
445
|
+
element.style.setProperty(property, value, "important")
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function lockDocumentScroll() {
|
|
450
|
+
const root = document.documentElement
|
|
451
|
+
const body = document.body
|
|
452
|
+
if (!root || !body) return
|
|
453
|
+
documentScrollLockCount += 1
|
|
454
|
+
if (documentScrollLockCount !== 1) return
|
|
455
|
+
|
|
456
|
+
const viewportWidth = Math.max(1, window.innerWidth || root.clientWidth || body.clientWidth || 1)
|
|
457
|
+
const viewportHeight = Math.max(1, window.innerHeight || root.clientHeight || body.clientHeight || 1)
|
|
458
|
+
const lockedWidth = `${viewportWidth}px`
|
|
459
|
+
const lockedHeight = `${viewportHeight}px`
|
|
460
|
+
documentScrollPosition = {
|
|
461
|
+
left: Number.isFinite(window.scrollX) ? window.scrollX : 0,
|
|
462
|
+
top: Number.isFinite(window.scrollY) ? window.scrollY : 0,
|
|
463
|
+
}
|
|
464
|
+
documentScrollRestore = [
|
|
465
|
+
rememberInlineStyles(root, [
|
|
466
|
+
"overflow",
|
|
467
|
+
"width",
|
|
468
|
+
"min-width",
|
|
469
|
+
"max-width",
|
|
470
|
+
"height",
|
|
471
|
+
"min-height",
|
|
472
|
+
"max-height",
|
|
473
|
+
"scrollbar-width",
|
|
474
|
+
"scrollbar-color",
|
|
475
|
+
]),
|
|
476
|
+
rememberInlineStyles(body, [
|
|
477
|
+
"overflow",
|
|
478
|
+
"position",
|
|
479
|
+
"top",
|
|
480
|
+
"right",
|
|
481
|
+
"left",
|
|
482
|
+
"width",
|
|
483
|
+
"min-width",
|
|
484
|
+
"max-width",
|
|
485
|
+
"height",
|
|
486
|
+
"min-height",
|
|
487
|
+
"max-height",
|
|
488
|
+
"scrollbar-width",
|
|
489
|
+
"scrollbar-color",
|
|
490
|
+
]),
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
// Chrome の action popup は documentElement.scrollHeight が上限 (600px) を超えると、
|
|
494
|
+
// overflow:hidden でもブラウザ側の外スクロールバーを強制する。現在の viewport 寸法へ
|
|
495
|
+
// レイアウト自体を固定し、body を通常フローから外すことで dialog の1本だけにする。
|
|
496
|
+
// fixed body の幅解決は Chromium のビルド差があるため、左右位置だけでなく pixel 幅も固定する。
|
|
497
|
+
applyLockedStyles(root, {
|
|
498
|
+
overflow: "hidden",
|
|
499
|
+
width: lockedWidth,
|
|
500
|
+
"min-width": lockedWidth,
|
|
501
|
+
"max-width": lockedWidth,
|
|
502
|
+
height: lockedHeight,
|
|
503
|
+
"min-height": lockedHeight,
|
|
504
|
+
"max-height": lockedHeight,
|
|
505
|
+
"scrollbar-width": "none",
|
|
506
|
+
"scrollbar-color": "transparent transparent",
|
|
507
|
+
})
|
|
508
|
+
applyLockedStyles(body, {
|
|
509
|
+
overflow: "hidden",
|
|
510
|
+
position: "fixed",
|
|
511
|
+
top: "0",
|
|
512
|
+
right: "0",
|
|
513
|
+
left: "0",
|
|
514
|
+
width: lockedWidth,
|
|
515
|
+
"min-width": lockedWidth,
|
|
516
|
+
"max-width": lockedWidth,
|
|
517
|
+
height: lockedHeight,
|
|
518
|
+
"min-height": lockedHeight,
|
|
519
|
+
"max-height": lockedHeight,
|
|
520
|
+
"scrollbar-width": "none",
|
|
521
|
+
"scrollbar-color": "transparent transparent",
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function unlockDocumentScroll() {
|
|
526
|
+
if (documentScrollLockCount === 0) return
|
|
527
|
+
documentScrollLockCount -= 1
|
|
528
|
+
if (documentScrollLockCount !== 0) return
|
|
529
|
+
|
|
530
|
+
for (const { element, properties } of documentScrollRestore) {
|
|
531
|
+
for (const { property, value, priority } of properties) {
|
|
532
|
+
if (value) element.style.setProperty(property, value, priority)
|
|
533
|
+
else element.style.removeProperty(property)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
documentScrollRestore = []
|
|
537
|
+
if (documentScrollPosition.left || documentScrollPosition.top) {
|
|
538
|
+
try {
|
|
539
|
+
window.scrollTo(documentScrollPosition.left, documentScrollPosition.top)
|
|
540
|
+
} catch {
|
|
541
|
+
// Embedded or hardened contexts can reject programmatic scrolling.
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
documentScrollPosition = { left: 0, top: 0 }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
class KagayoiContactPopup extends HTMLElement {
|
|
548
|
+
constructor() {
|
|
549
|
+
super()
|
|
550
|
+
this.attachShadow({ mode: "open" })
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
connectedCallback() {
|
|
554
|
+
if (this.shadowRoot.childElementCount) return
|
|
555
|
+
const popupId = `kagayoi-support-popup-${++popupCount}`
|
|
556
|
+
replaceShadowContent(this.shadowRoot, POPUP_STYLESHEET_URL, `
|
|
557
|
+
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="${popupId}">お問い合わせ</button>
|
|
558
|
+
<dialog id="${popupId}" aria-labelledby="${popupId}-title">
|
|
559
|
+
<div class="shell">
|
|
560
|
+
<header class="header">
|
|
561
|
+
<h2 class="title" id="${popupId}-title">お問い合わせ</h2>
|
|
562
|
+
<button class="close" type="button" aria-label="閉じる">×</button>
|
|
563
|
+
</header>
|
|
564
|
+
<div class="form-host"></div>
|
|
565
|
+
</div>
|
|
566
|
+
</dialog>
|
|
567
|
+
`)
|
|
568
|
+
this.trigger = this.shadowRoot.querySelector(".trigger")
|
|
569
|
+
this.dialog = this.shadowRoot.querySelector("dialog")
|
|
570
|
+
this.form = document.createElement("kagayoi-contact-form")
|
|
571
|
+
this.form.setAttribute("channel", "extension")
|
|
572
|
+
this.form.setAttribute("storage", "local")
|
|
573
|
+
this.trigger.textContent = this.getAttribute("button-label")?.trim() || "お問い合わせ"
|
|
574
|
+
this.trigger.hidden = this.hasAttribute("hide-trigger")
|
|
575
|
+
this.shadowRoot.querySelector(".title").textContent = this.getAttribute("dialog-title")?.trim() || "お問い合わせ"
|
|
576
|
+
for (const name of ["product-id", "product-name", "api-base", "app-version", "os-version", "locale", "diagnostics", "firefox-data-consent"]) {
|
|
577
|
+
if (this.hasAttribute(name)) this.form.setAttribute(name, this.getAttribute(name))
|
|
578
|
+
}
|
|
579
|
+
this.shadowRoot.querySelector(".form-host").append(this.form)
|
|
580
|
+
this.trigger.addEventListener("click", () => this.open())
|
|
581
|
+
this.shadowRoot.querySelector(".close").addEventListener("click", () => this.close())
|
|
582
|
+
this.dialog.addEventListener("click", (event) => {
|
|
583
|
+
if (event.target === this.dialog) this.close()
|
|
584
|
+
})
|
|
585
|
+
this.dialog.addEventListener("close", () => {
|
|
586
|
+
this.finishClose()
|
|
587
|
+
})
|
|
588
|
+
if (this.hasAttribute("open")) {
|
|
589
|
+
queueMicrotask(() => {
|
|
590
|
+
if (this.isConnected) this.open()
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
disconnectedCallback() {
|
|
596
|
+
this.releaseDocumentScrollLock()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
open(returnFocusTo = this.trigger) {
|
|
600
|
+
if (!this.isConnected || !this.dialog || this.dialog.open) return
|
|
601
|
+
this.returnFocusTo = returnFocusTo
|
|
602
|
+
this.acquireDocumentScrollLock()
|
|
603
|
+
try {
|
|
604
|
+
if (typeof this.dialog.showModal === "function") this.dialog.showModal()
|
|
605
|
+
else this.dialog.setAttribute("open", "")
|
|
606
|
+
} catch (error) {
|
|
607
|
+
this.releaseDocumentScrollLock()
|
|
608
|
+
throw error
|
|
609
|
+
}
|
|
610
|
+
queueMicrotask(() => this.form?.shadowRoot?.querySelector('input[name="email"]')?.focus())
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
close() {
|
|
614
|
+
if (!this.dialog?.open) return
|
|
615
|
+
if (typeof this.dialog.close === "function") this.dialog.close()
|
|
616
|
+
else this.dialog.removeAttribute("open")
|
|
617
|
+
this.finishClose()
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
finishClose() {
|
|
621
|
+
if (!this.documentScrollLocked) return
|
|
622
|
+
this.releaseDocumentScrollLock()
|
|
623
|
+
this.restoreFocus()
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
restoreFocus() {
|
|
627
|
+
const target = this.returnFocusTo?.isConnected ? this.returnFocusTo : this.trigger
|
|
628
|
+
target?.focus()
|
|
629
|
+
this.returnFocusTo = this.trigger
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
acquireDocumentScrollLock() {
|
|
633
|
+
if (this.documentScrollLocked) return
|
|
634
|
+
this.documentScrollLocked = true
|
|
635
|
+
lockDocumentScroll()
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
releaseDocumentScrollLock() {
|
|
639
|
+
if (!this.documentScrollLocked) return
|
|
640
|
+
this.documentScrollLocked = false
|
|
641
|
+
unlockDocumentScroll()
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!customElements.get("kagayoi-contact-form")) customElements.define("kagayoi-contact-form", KagayoiContactForm)
|
|
646
|
+
if (!customElements.get("kagayoi-contact-popup")) customElements.define("kagayoi-contact-popup", KagayoiContactPopup)
|
|
647
|
+
})()
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kagayoi/support-extension",
|
|
3
|
+
"version": "1.0.6",
|
|
4
|
+
"description": "Kagayoi製Chrome拡張へ同梱する問い合わせフォームとサポートフッター",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"author": "Kagayoi",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/1llum1n4t1s/Kagayoi.Support.git",
|
|
11
|
+
"directory": "clients/chrome-extension"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://support.kagayoi.com",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"chrome-extension",
|
|
16
|
+
"firefox-extension",
|
|
17
|
+
"manifest-v3",
|
|
18
|
+
"support"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin/",
|
|
25
|
+
"dist/",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"kagayoi-support-sync": "bin/kagayoi-support-sync.mjs"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "node ../../scripts/build-extension-package.mjs",
|
|
33
|
+
"prepack": "pnpm run build"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|