@umec/core 0.1.0-alpha.10 → 0.1.0-alpha.12

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.
@@ -0,0 +1,231 @@
1
+ import { Resend } from 'resend';
2
+ import { writeAuditLog, type AdminAuditEntry } from '@umec/core/db';
3
+ import { authenticateAdmin } from './access';
4
+ import { jsonError, jsonOk, readJsonObject, type AdminApiHandler } from './admin-api';
5
+ import {
6
+ getDisclosureRequest,
7
+ incrementSendAttempts,
8
+ insertDisclosureRequest,
9
+ markDisclosed,
10
+ type DisclosureRequestRow,
11
+ } from './disclosure-db';
12
+ import {
13
+ createDisclosureRequestId,
14
+ EMAIL_PATTERN,
15
+ parseDisclosurePayload,
16
+ renderRequesterDisclosureMail,
17
+ resolveDisclosureMode,
18
+ type DisclosureBrandContext,
19
+ type DisclosureEnv,
20
+ type DisclosurePayload,
21
+ } from './disclosure-request';
22
+ import { getServerEnv } from './env';
23
+
24
+ export type DisclosureSendKind = 'resend' | 'manual';
25
+
26
+ export type DisclosureSendHandlerOptions = Pick<
27
+ DisclosureBrandContext,
28
+ 'brandName' | 'emailFrom' | 'emailReplyTo'
29
+ > & {
30
+ now?: () => number;
31
+ createId?: () => string;
32
+ };
33
+
34
+ function asDisclosureEnv(env: unknown): DisclosureEnv {
35
+ return env as DisclosureEnv;
36
+ }
37
+
38
+ async function sendDisclosureEmail(input: {
39
+ apiKey: string;
40
+ from: string;
41
+ to: string;
42
+ replyTo?: string;
43
+ requestId: string;
44
+ receivedAtMs: number;
45
+ brandName: string;
46
+ payload: DisclosurePayload;
47
+ }): Promise<{ id: string } | { error: unknown }> {
48
+ const mail = renderRequesterDisclosureMail({
49
+ brandName: input.brandName,
50
+ requestId: input.requestId,
51
+ receivedAtMs: input.receivedAtMs,
52
+ payload: input.payload,
53
+ });
54
+ const resend = new Resend(input.apiKey);
55
+ const { data, error } = await resend.emails.send({
56
+ from: input.from,
57
+ to: input.to,
58
+ subject: mail.subject,
59
+ text: mail.text,
60
+ html: mail.html,
61
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
62
+ });
63
+ if (error || !data?.id) {
64
+ return { error: error ?? new Error('Resend did not return an email id') };
65
+ }
66
+ return { id: data.id };
67
+ }
68
+
69
+ async function recordDiscloseAudit(input: {
70
+ db: NonNullable<DisclosureEnv['DB']>;
71
+ requestId: string;
72
+ adminEmail: string;
73
+ kind: DisclosureSendKind;
74
+ disclosureEmailId: string;
75
+ }): Promise<void> {
76
+ const entry = {
77
+ entityType: 'disclosure',
78
+ entityId: input.requestId,
79
+ action: 'disclose',
80
+ adminEmail: input.adminEmail,
81
+ actor: 'human' as const,
82
+ detail: {
83
+ kind: input.kind,
84
+ disclosure_email_id: input.disclosureEmailId,
85
+ },
86
+ };
87
+ await writeAuditLog(input.db, entry as AdminAuditEntry);
88
+ }
89
+
90
+ async function sendAndRecord(input: {
91
+ env: DisclosureEnv;
92
+ brand: DisclosureSendHandlerOptions;
93
+ payload: DisclosurePayload;
94
+ row: DisclosureRequestRow;
95
+ adminEmail: string;
96
+ kind: DisclosureSendKind;
97
+ now: number;
98
+ }): Promise<Response> {
99
+ const db = input.env.DB;
100
+ const apiKey = input.env.RESEND_API_KEY;
101
+ if (!db || !apiKey) return jsonError('Disclosure service unavailable', 503);
102
+
103
+ let sendResult: { id: string } | { error: unknown };
104
+ try {
105
+ sendResult = await sendDisclosureEmail({
106
+ apiKey,
107
+ from: input.brand.emailFrom,
108
+ to: input.row.requester_email,
109
+ replyTo: input.brand.emailReplyTo,
110
+ requestId: input.row.id,
111
+ receivedAtMs: input.row.received_at,
112
+ brandName: input.brand.brandName,
113
+ payload: input.payload,
114
+ });
115
+ } catch (error) {
116
+ sendResult = { error };
117
+ }
118
+
119
+ await incrementSendAttempts(db, input.row.id);
120
+
121
+ if ('error' in sendResult) {
122
+ console.error('[admin/disclosure-send] send failed', { id: input.row.id });
123
+ return jsonError('Failed to send disclosure email', 502);
124
+ }
125
+
126
+ if (!input.row.disclosure_email_id) {
127
+ await markDisclosed(db, input.row.id, {
128
+ disclosedAt: input.now,
129
+ disclosureEmailId: sendResult.id,
130
+ });
131
+ }
132
+
133
+ console.log(
134
+ JSON.stringify({
135
+ type: 'admin.audit',
136
+ action: 'disclose',
137
+ actor: input.adminEmail,
138
+ targetId: input.row.id,
139
+ }),
140
+ );
141
+
142
+ await recordDiscloseAudit({
143
+ db,
144
+ requestId: input.row.id,
145
+ adminEmail: input.adminEmail,
146
+ kind: input.kind,
147
+ disclosureEmailId: sendResult.id,
148
+ });
149
+
150
+ return jsonOk({ ok: true, id: input.row.id });
151
+ }
152
+
153
+ export function createDisclosureSendHandler(options: DisclosureSendHandlerOptions): AdminApiHandler {
154
+ return async ({ request }) => {
155
+ const auth = await authenticateAdmin(request);
156
+ if (auth instanceof Response) return auth;
157
+
158
+ const body = await readJsonObject(request);
159
+ if (body instanceof Response) return body;
160
+
161
+ const env = asDisclosureEnv(getServerEnv());
162
+ if (!env.DB) return jsonError('DB not available', 503);
163
+ if (!env.RESEND_API_KEY) return jsonError('Mail is not configured', 503);
164
+
165
+ const payload = parseDisclosurePayload(env.DISCLOSURE_PAYLOAD);
166
+ if (!payload) return jsonError('Disclosure payload is not configured', 503);
167
+
168
+ const now = options.now?.() ?? Date.now();
169
+ const id = typeof body.id === 'string' ? body.id.trim() : '';
170
+ const emailRaw = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
171
+
172
+ if (id) {
173
+ const row = await getDisclosureRequest(env.DB, id);
174
+ if (!row) return jsonError('Request not found', 404);
175
+ return sendAndRecord({
176
+ env,
177
+ brand: options,
178
+ payload,
179
+ row,
180
+ adminEmail: auth.email,
181
+ kind: 'resend',
182
+ now,
183
+ });
184
+ }
185
+
186
+ if (emailRaw) {
187
+ if (resolveDisclosureMode(env.DISCLOSURE_MODE) !== 'manual') {
188
+ return jsonError('Manual disclosure is not enabled', 400);
189
+ }
190
+ if (!EMAIL_PATTERN.test(emailRaw)) {
191
+ return jsonError('Invalid email', 400);
192
+ }
193
+
194
+ const requestId = options.createId?.() ?? createDisclosureRequestId();
195
+ await insertDisclosureRequest(env.DB, {
196
+ id: requestId,
197
+ requesterEmail: emailRaw,
198
+ receivedAt: now,
199
+ ipHash: null,
200
+ userAgent: null,
201
+ });
202
+
203
+ const row = {
204
+ id: requestId,
205
+ requester_email: emailRaw,
206
+ status: 'received' as const,
207
+ received_at: now,
208
+ disclosed_at: null,
209
+ disclosure_email_id: null,
210
+ send_attempts: 0,
211
+ receipt_email_id: null,
212
+ operator_notified_at: null,
213
+ ip_hash: null,
214
+ user_agent: null,
215
+ created_at: now,
216
+ };
217
+
218
+ return sendAndRecord({
219
+ env,
220
+ brand: options,
221
+ payload,
222
+ row,
223
+ adminEmail: auth.email,
224
+ kind: 'manual',
225
+ now,
226
+ });
227
+ }
228
+
229
+ return jsonError('Invalid request body', 400);
230
+ };
231
+ }
@@ -0,0 +1,269 @@
1
+ ---
2
+ import config from '../../../umec.config';
3
+ import { requireAdminAuth } from '../../lib/access';
4
+ import { listDisclosureRequests, listUndisclosed, type DisclosureRequestRow } from '../../lib/disclosure-db';
5
+ import {
6
+ DEFAULT_RETRY_MAX,
7
+ parsePositiveInt,
8
+ resolveDisclosureMode,
9
+ type DisclosureEnv,
10
+ } from '../../lib/disclosure-request';
11
+ import { getServerEnv } from '../../lib/env';
12
+ import '../../styles/global.css';
13
+
14
+ export const prerender = false;
15
+
16
+ const denied = await requireAdminAuth(Astro.request);
17
+ if (denied) return denied;
18
+
19
+ Astro.response.headers.set('Cache-Control', 'no-store');
20
+
21
+ const brandName = config.brand.name;
22
+ const env = getServerEnv() as DisclosureEnv;
23
+ const { DB: db } = env;
24
+ const retryMax = parsePositiveInt(env.DISCLOSURE_RETRY_MAX, DEFAULT_RETRY_MAX);
25
+ const disclosureMode = resolveDisclosureMode(env.DISCLOSURE_MODE);
26
+ const isManualMode = disclosureMode === 'manual';
27
+
28
+ let requests: DisclosureRequestRow[] = [];
29
+ let undisclosed: DisclosureRequestRow[] = [];
30
+ let dbNotice = '';
31
+
32
+ if (db) {
33
+ try {
34
+ requests = await listDisclosureRequests(db, { limit: 100 });
35
+ undisclosed = await listUndisclosed(db);
36
+ } catch (error) {
37
+ console.error('[admin/disclosure] query failed:', error);
38
+ dbNotice = 'Disclosure requests could not be loaded.';
39
+ }
40
+ } else {
41
+ dbNotice = 'DB binding is not available.';
42
+ }
43
+
44
+ function formatDate(value: number | string | null) {
45
+ if (value == null || value === '' || value === 0) return '-';
46
+ const timestamp = typeof value === 'string' ? Number(value) : value;
47
+ if (!Number.isFinite(timestamp)) return '-';
48
+
49
+ return new Intl.DateTimeFormat('ja-JP', {
50
+ dateStyle: 'medium',
51
+ timeStyle: 'short',
52
+ timeZone: 'Asia/Tokyo',
53
+ }).format(new Date(timestamp));
54
+ }
55
+
56
+ function isUndisclosed(row: DisclosureRequestRow) {
57
+ return row.disclosure_email_id == null;
58
+ }
59
+
60
+ function isRetryExhausted(row: DisclosureRequestRow) {
61
+ return isUndisclosed(row) && row.send_attempts >= retryMax;
62
+ }
63
+
64
+ function sendStatusLabel(row: DisclosureRequestRow) {
65
+ if (!isUndisclosed(row)) return '送信済み';
66
+ if (row.send_attempts >= retryMax) return 'リトライ上限超え';
67
+ if (row.send_attempts > 0) return `リトライ中 (${row.send_attempts}/${retryMax})`;
68
+ return '未送信';
69
+ }
70
+
71
+ const exhaustedCount = undisclosed.filter((row) => row.send_attempts >= retryMax).length;
72
+ ---
73
+
74
+ <!doctype html>
75
+ <html lang="ja">
76
+ <head>
77
+ <meta charset="utf-8" />
78
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
79
+ <meta name="robots" content="noindex, nofollow" />
80
+ <title>開示請求 | {brandName} admin</title>
81
+ </head>
82
+ <body class="min-h-screen bg-bg text-text">
83
+ <main class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
84
+ <header class="flex flex-col gap-3 border-b border-border pb-6 sm:flex-row sm:items-end sm:justify-between">
85
+ <div>
86
+ <p class="font-serif text-2xl leading-none">{brandName}</p>
87
+ <h1 class="mt-3 text-xl font-medium tracking-normal">開示請求</h1>
88
+ <p class="mt-2 text-sm text-muted">
89
+ モード: {isManualMode ? '手動開示' : '即時自動'}
90
+ </p>
91
+ </div>
92
+ <div class="flex flex-wrap items-center gap-4">
93
+ <a href="/admin/orders" class="text-sm underline decoration-border underline-offset-4 hover:text-accent">注文一覧</a>
94
+ <a href="/admin/inventory" class="text-sm underline decoration-border underline-offset-4 hover:text-accent">在庫管理</a>
95
+ <a href="/admin/products" class="text-sm underline decoration-border underline-offset-4 hover:text-accent">商品管理</a>
96
+ </div>
97
+ </header>
98
+
99
+ {dbNotice && (
100
+ <p class="border border-border bg-bg px-4 py-3 text-sm text-muted">{dbNotice}</p>
101
+ )}
102
+
103
+ {undisclosed.length > 0 && (
104
+ <p
105
+ class="border border-accent bg-[color-mix(in_srgb,var(--color-accent)_12%,transparent)] px-4 py-3 text-sm"
106
+ data-undisclosed-warning
107
+ >
108
+ 未開示の請求が {undisclosed.length} 件あります。
109
+ {exhaustedCount > 0 ? `うちリトライ上限超えは ${exhaustedCount} 件です。` : ''}
110
+ </p>
111
+ )}
112
+
113
+ {isManualMode && (
114
+ <section class="border border-border bg-bg p-4">
115
+ <h2 class="mb-4 text-sm font-medium">手動開示</h2>
116
+ <p class="mb-4 text-sm text-muted">
117
+ 請求フォームからの受付は即時送信しません。メールアドレスを指定して開示メールを送れます。
118
+ </p>
119
+ <form class="flex max-w-xl flex-col gap-3 sm:flex-row sm:items-end" data-manual-disclose>
120
+ <label class="flex-1 text-sm">メールアドレス
121
+ <input
122
+ name="email"
123
+ type="email"
124
+ required
125
+ autocomplete="off"
126
+ class="mt-1 w-full border border-border bg-bg px-3 py-2"
127
+ placeholder="email@example.com"
128
+ />
129
+ </label>
130
+ <button
131
+ type="submit"
132
+ class="border border-accent px-4 py-2 text-sm text-accent transition hover:bg-accent hover:text-bg disabled:cursor-not-allowed disabled:border-muted disabled:text-muted disabled:hover:bg-transparent"
133
+ >
134
+ 開示メールを送信
135
+ </button>
136
+ </form>
137
+ </section>
138
+ )}
139
+
140
+ <section class="overflow-x-auto border border-border bg-bg">
141
+ <table class="min-w-full border-collapse text-left text-sm">
142
+ <thead class="text-xs font-medium text-muted">
143
+ <tr>
144
+ <th class="whitespace-nowrap border-b border-border px-4 py-3">請求番号</th>
145
+ <th class="whitespace-nowrap border-b border-border px-4 py-3">請求者メール</th>
146
+ <th class="whitespace-nowrap border-b border-border px-4 py-3">受付日時</th>
147
+ <th class="whitespace-nowrap border-b border-border px-4 py-3">開示日時</th>
148
+ <th class="whitespace-nowrap border-b border-border px-4 py-3">送信状態</th>
149
+ <th class="whitespace-nowrap border-b border-border px-4 py-3">操作</th>
150
+ </tr>
151
+ </thead>
152
+ <tbody>
153
+ {requests.length === 0 ? (
154
+ <tr>
155
+ <td colspan="6" class="px-4 py-10 text-center text-muted">
156
+ 開示請求はまだありません。
157
+ </td>
158
+ </tr>
159
+ ) : (
160
+ requests.map((row) => (
161
+ <tr
162
+ class:list={[
163
+ 'align-top',
164
+ isUndisclosed(row) && 'bg-[color-mix(in_srgb,var(--color-accent)_12%,transparent)]',
165
+ ]}
166
+ data-request-row={row.id}
167
+ data-undisclosed={isUndisclosed(row) ? 'true' : 'false'}
168
+ data-retry-exhausted={isRetryExhausted(row) ? 'true' : 'false'}
169
+ >
170
+ <td class="whitespace-nowrap border-b border-border px-4 py-4 font-mono text-xs">{row.id}</td>
171
+ <td class="min-w-56 border-b border-border px-4 py-4">
172
+ <a
173
+ class="underline decoration-border underline-offset-4 hover:text-accent"
174
+ href={`mailto:${row.requester_email}`}
175
+ >
176
+ {row.requester_email}
177
+ </a>
178
+ </td>
179
+ <td class="whitespace-nowrap border-b border-border px-4 py-4 text-muted">
180
+ {formatDate(row.received_at)}
181
+ </td>
182
+ <td class="whitespace-nowrap border-b border-border px-4 py-4 text-muted">
183
+ {formatDate(row.disclosed_at)}
184
+ </td>
185
+ <td class="min-w-40 border-b border-border px-4 py-4">
186
+ <div data-send-status>{sendStatusLabel(row)}</div>
187
+ {isRetryExhausted(row) && (
188
+ <div class="mt-1 text-xs text-accent">要手動対応</div>
189
+ )}
190
+ {isUndisclosed(row) && !isRetryExhausted(row) && (
191
+ <div class="mt-1 text-xs text-accent">未開示</div>
192
+ )}
193
+ </td>
194
+ <td class="whitespace-nowrap border-b border-border px-4 py-4">
195
+ <button
196
+ type="button"
197
+ class="border border-accent px-3 py-2 text-xs font-medium text-accent transition hover:bg-accent hover:text-bg disabled:cursor-not-allowed disabled:border-muted disabled:text-muted disabled:hover:bg-transparent"
198
+ data-disclosure-send
199
+ data-request-id={row.id}
200
+ >
201
+ {isUndisclosed(row) ? '開示メールを送信' : '再送'}
202
+ </button>
203
+ </td>
204
+ </tr>
205
+ ))
206
+ )}
207
+ </tbody>
208
+ </table>
209
+ </section>
210
+ </main>
211
+
212
+ <script>
213
+ document.addEventListener('click', async (event) => {
214
+ const button = event.target instanceof Element ? event.target.closest('[data-disclosure-send]') : null;
215
+ if (!(button instanceof HTMLButtonElement)) return;
216
+ const id = button.dataset.requestId;
217
+ if (!id) return;
218
+
219
+ button.disabled = true;
220
+ const originalText = button.textContent;
221
+ button.textContent = '送信中';
222
+
223
+ try {
224
+ const response = await fetch('/api/admin/disclosure-send', {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({ id }),
228
+ });
229
+ const payload = await response.json().catch(() => ({}));
230
+ if (!response.ok) throw new Error(payload.error || '送信に失敗しました');
231
+ window.location.reload();
232
+ } catch (error) {
233
+ button.disabled = false;
234
+ button.textContent = originalText;
235
+ alert(error instanceof Error ? error.message : '送信に失敗しました');
236
+ }
237
+ });
238
+
239
+ document.querySelector('[data-manual-disclose]')?.addEventListener('submit', async (event) => {
240
+ event.preventDefault();
241
+ const form = event.currentTarget;
242
+ if (!(form instanceof HTMLFormElement)) return;
243
+ const emailInput = form.querySelector('input[name="email"]');
244
+ const button = form.querySelector('button[type="submit"]');
245
+ if (!(emailInput instanceof HTMLInputElement)) return;
246
+ const email = emailInput.value.trim();
247
+ if (!email) {
248
+ alert('メールアドレスを入力してください。');
249
+ return;
250
+ }
251
+
252
+ if (button instanceof HTMLButtonElement) button.disabled = true;
253
+ try {
254
+ const response = await fetch('/api/admin/disclosure-send', {
255
+ method: 'POST',
256
+ headers: { 'Content-Type': 'application/json' },
257
+ body: JSON.stringify({ email }),
258
+ });
259
+ const payload = await response.json().catch(() => ({}));
260
+ if (!response.ok) throw new Error(payload.error || '送信に失敗しました');
261
+ window.location.reload();
262
+ } catch (error) {
263
+ if (button instanceof HTMLButtonElement) button.disabled = false;
264
+ alert(error instanceof Error ? error.message : '送信に失敗しました');
265
+ }
266
+ });
267
+ </script>
268
+ </body>
269
+ </html>
@@ -0,0 +1,11 @@
1
+ import { formatEmailFrom } from '@umec/core/config';
2
+ import config from '../../../../umec.config';
3
+ import { createDisclosureSendHandler } from '../../../lib/disclosure-send';
4
+
5
+ export const prerender = false;
6
+
7
+ export const POST = createDisclosureSendHandler({
8
+ brandName: config.brand.name,
9
+ emailFrom: formatEmailFrom(config.email),
10
+ emailReplyTo: config.email.replyTo,
11
+ });
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { formatEmailFrom } from '@umec/core/config';
3
+ import config from '../../../umec.config';
4
+ import { createDisclosureRequestHandler } from '../../lib/disclosure-request';
5
+
6
+ export const prerender = false;
7
+
8
+ export const POST: APIRoute = (context) =>
9
+ createDisclosureRequestHandler({
10
+ isProduction: import.meta.env.PROD,
11
+ brandName: config.brand.name,
12
+ brandUrl: String(config.brand.url),
13
+ emailFrom: formatEmailFrom(config.email),
14
+ emailReplyTo: config.email.replyTo,
15
+ notifyFallback: config.email.bcc,
16
+ })({ request: context.request, clientAddress: context.clientAddress });
@@ -0,0 +1,135 @@
1
+ ---
2
+ import Layout from '../../layouts/Layout.astro';
3
+ import Footer from '../../components/Footer.astro';
4
+
5
+ export const prerender = true;
6
+
7
+ const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY;
8
+ const hasTurnstile = typeof turnstileSiteKey === 'string' && turnstileSiteKey.length > 0;
9
+ ---
10
+
11
+ <Layout title="特定商取引法に基づく表示事項の開示請求">
12
+ <main class="px-6 py-24 md:px-16">
13
+ <div class="mx-auto max-w-4xl">
14
+ <p class="mb-8 text-xs uppercase tracking-[0.28em] text-[var(--color-muted)]">legal</p>
15
+ <h1 class="mb-8 font-serif text-3xl leading-relaxed md:text-5xl">
16
+ 表示事項の開示請求
17
+ </h1>
18
+
19
+ <div class="space-y-8 text-sm leading-relaxed text-[var(--color-muted)]">
20
+ <p>
21
+ 送信いただいたメールアドレス宛に、販売事業者名・運営責任者名・所在地・電話番号・メールアドレスを<strong class="text-[var(--color-text)]">直ちに</strong>お送りします。
22
+ </p>
23
+ <p>
24
+ ご入力いただいたメールアドレスは、特定商取引法に基づく表示事項の提供、到達確認、および濫用防止の記録のみに利用し、マーケティング等の目的には利用しません。
25
+ </p>
26
+ </div>
27
+
28
+ <form id="disclosure-form" class="mt-12 flex max-w-xl flex-col gap-6">
29
+ <label class="flex flex-col gap-2 text-sm">
30
+ <span class="text-[var(--color-muted)]">メールアドレス</span>
31
+ <input
32
+ id="disclosure-email"
33
+ type="email"
34
+ name="email"
35
+ required
36
+ autocomplete="email"
37
+ class="min-h-12 border border-[var(--color-border)] bg-[var(--color-bg)] px-4 text-sm outline-none focus:border-[var(--color-text)]"
38
+ placeholder="email@example.com"
39
+ />
40
+ </label>
41
+
42
+ {hasTurnstile && (
43
+ <div
44
+ class="cf-turnstile"
45
+ data-sitekey={turnstileSiteKey}
46
+ data-theme="light"
47
+ data-language="ja"
48
+ ></div>
49
+ )}
50
+
51
+ <button
52
+ id="disclosure-submit"
53
+ type="submit"
54
+ class="border border-[var(--color-text)] px-8 py-3 text-sm tracking-widest transition-colors hover:bg-[var(--color-text)] hover:text-[var(--color-bg)] disabled:cursor-not-allowed disabled:opacity-50"
55
+ >
56
+ 開示を請求する
57
+ </button>
58
+ </form>
59
+
60
+ <p id="disclosure-success" class="mt-10 hidden max-w-xl text-sm leading-relaxed text-[var(--color-muted)]">
61
+ 入力いただいたメールアドレス宛に開示事項を送信しました。メールが届かない場合は、迷惑メールフォルダをご確認のうえ、時間をおいて再度ご請求ください。
62
+ </p>
63
+ <p id="disclosure-error" class="mt-6 hidden max-w-xl text-sm leading-relaxed text-[var(--color-muted)]" role="alert">
64
+ 送信できませんでした。入力内容をご確認のうえ、時間をおいて再度お試しください。
65
+ </p>
66
+
67
+ <p class="mt-16 text-xs text-[var(--color-muted)]">
68
+ <a href="/legal/tokushoho" class="underline underline-offset-4 transition-colors hover:text-[var(--color-text)]">
69
+ 特定商取引法に基づく表記へ戻る
70
+ </a>
71
+ </p>
72
+ </div>
73
+ </main>
74
+ <Footer />
75
+
76
+ {hasTurnstile && (
77
+ <script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
78
+ )}
79
+
80
+ <script>
81
+ const form = document.getElementById('disclosure-form');
82
+ const submitButton = document.getElementById('disclosure-submit');
83
+ const success = document.getElementById('disclosure-success');
84
+ const errorMessage = document.getElementById('disclosure-error');
85
+
86
+ const readTurnstileToken = () => {
87
+ const input = form?.querySelector(
88
+ 'input[name="cf-turnstile-response"], textarea[name="cf-turnstile-response"]',
89
+ );
90
+ if (input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) {
91
+ return input.value;
92
+ }
93
+ return '';
94
+ };
95
+
96
+ const resetTurnstile = () => {
97
+ const turnstile = (
98
+ window as Window & { turnstile?: { reset: () => void } }
99
+ ).turnstile;
100
+ turnstile?.reset();
101
+ };
102
+
103
+ form?.addEventListener('submit', async (event) => {
104
+ event.preventDefault();
105
+ if (!(form instanceof HTMLFormElement) || !(submitButton instanceof HTMLButtonElement)) return;
106
+
107
+ errorMessage?.classList.add('hidden');
108
+ submitButton.disabled = true;
109
+
110
+ const formData = new FormData(form);
111
+ const email = String(formData.get('email') || '');
112
+ const turnstileToken = readTurnstileToken();
113
+
114
+ try {
115
+ const response = await fetch('/api/disclosure-request', {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ email, turnstileToken }),
119
+ });
120
+
121
+ if (!response.ok) {
122
+ throw new Error(`disclosure-request failed: ${response.status}`);
123
+ }
124
+
125
+ form.classList.add('hidden');
126
+ success?.classList.remove('hidden');
127
+ } catch (error) {
128
+ console.error('[disclosure] request error:', error);
129
+ errorMessage?.classList.remove('hidden');
130
+ resetTurnstile();
131
+ submitButton.disabled = false;
132
+ }
133
+ });
134
+ </script>
135
+ </Layout>