Notify/OperatorInfoRequestBlock
Default
Error State
Loading State
Always Open
operator-info-request-block.tsx
'use client';
import type { TargetedEvent } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import { ActionButton } from '@/components/shared/action-button';
import { BaseDialog } from './base-dialog';
import { FormInput, SubmitButton, StatusMessage } from './form-components';
import type { FormState, TokushohoDisclosureRequest, TokushohoDisclosureResponse } from './types';
interface OperatorInfoRequestBlockProps {
/** Optional custom API endpoint for testing */
apiEndpoint?: string;
/** Optional: custom portal container for embedded rendering (e.g. Storybook) */
portalContainer?: HTMLElement | null;
/** Optional: render with the dialog already open (Storybook / testing) */
defaultOpen?: boolean;
}
// The dialog is all-Japanese — map the API's English error codes to Japanese copy
// rather than surfacing a raw code like `rate-limited` to the visitor.
const ERROR_MESSAGES: Record<string, string> = {
'invalid-email': 'メールアドレスの形式が正しくありません。',
'rate-limited': 'リクエストが集中しています。しばらく時間をおいてから、もう一度お試しください。',
};
const GENERIC_ERROR = '送信に失敗しました。もう一度お試しください。';
/**
* OperatorInfoRequestBlock Component
*
* Renders a 「運営者情報を請求する」 button that opens a dialog with
* a single email field. On submit, POSTs { email, bot_field } to
* /api/tokushoho-disclosure to trigger an automatic reply email containing
* the operator's 販売業者/代表者名/住所/電話番号 (特商法 11条ただし書 compliance).
*/
export const OperatorInfoRequestBlock = ({
apiEndpoint = '/api/tokushoho-disclosure',
portalContainer,
defaultOpen = false,
}: OperatorInfoRequestBlockProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(defaultOpen);
const [email, setEmail] = useState('');
const [formState, setFormState] = useState<FormState>({ status: 'idle' });
const handleSubmit = useCallback(
async (e: TargetedEvent<HTMLFormElement>) => {
e.preventDefault();
if (!email.trim()) return;
setFormState({ status: 'submitting' });
try {
const payload: TokushohoDisclosureRequest = { email: email.trim(), bot_field: '' };
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const contentType = response.headers.get('content-type') ?? '';
if (!response.ok || !contentType.includes('application/json')) {
setFormState({
status: 'error',
errorMessage: 'サーバーエラーが発生しました。もう一度お試しください。',
});
return;
}
const data: TokushohoDisclosureResponse = await response.json();
if (data.success) {
setFormState({ status: 'success' });
setEmail('');
} else {
setFormState({
status: 'error',
errorMessage: (data.error && ERROR_MESSAGES[data.error]) || GENERIC_ERROR,
});
}
} catch {
setFormState({
status: 'error',
errorMessage: 'ネットワークエラーが発生しました。もう一度お試しください。',
});
}
},
[email, apiEndpoint],
);
const handleClose = useCallback(() => {
setFormState({ status: 'idle' });
setEmail('');
setIsDialogOpen(false);
}, []);
const isSubmitting = formState.status === 'submitting';
const isSuccess = formState.status === 'success';
const isError = formState.status === 'error';
return (
<div>
<ActionButton onClick={() => setIsDialogOpen(true)}>運営者情報を請求する</ActionButton>
{/* Dialog */}
<BaseDialog
isOpen={isDialogOpen}
onClose={handleClose}
title="運営者情報の請求"
ariaDescribedBy="operator-info-request-description"
portalContainer={portalContainer}
>
<div id="operator-info-request-description" className="pb-vgap-sm text-zd-white">
<p>
メールアドレスをご入力いただくと、運営者情報(住所・電話番号)をメールにてお送りします。
</p>
</div>
{isSuccess ? (
<StatusMessage type="success">
ご入力のメールアドレス宛に運営者情報をお送りしました。
<br />
<code>notify@takazudomodular.com</code>{' '}
から届きます(迷惑メールフォルダもご確認ください)。
</StatusMessage>
) : (
<form onSubmit={handleSubmit}>
<FormInput
id="operator-info-request-email"
label="メールアドレス"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="example@example.com"
required
disabled={isSubmitting}
autoComplete="email"
autoFocus
/>
{isError && (
<div className="pb-vgap-sm">
<StatusMessage type="error">{formState.errorMessage}</StatusMessage>
</div>
)}
<div className="pt-vgap-xs">
<SubmitButton disabled={!email.trim()} isLoading={isSubmitting}>
請求する
</SubmitButton>
</div>
</form>
)}
</BaseDialog>
</div>
);
};
export default OperatorInfoRequestBlock;
operator-info-request-block.stories.tsx
import { http, HttpResponse, delay } from 'msw';
import { OperatorInfoRequestBlock } from './operator-info-request-block';
import { DialogStoryContainer } from '@/.storybook/dialog-story-container';
/**
* OperatorInfoRequestBlock Component Stories
*
* Block with a 「運営者情報を請求する」 button that opens a dialog and
* POSTs { email, bot_field } to /api/tokushoho-disclosure.
*
* All stories use embedded portal container to avoid blocking styleguide UI.
*/
export const meta = {
title: 'Notify/OperatorInfoRequestBlock',
};
/**
* Default State
*
* Interactive block with MSW mocked API (success).
*/
export const Default = () => (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<OperatorInfoRequestBlock portalContainer={portalContainer} />
</div>
)}
</DialogStoryContainer>
);
Default.parameters = {
msw: {
handlers: [
http.post('/api/tokushoho-disclosure', async () => {
await delay(500);
return HttpResponse.json({ success: true });
}),
],
},
};
/**
* Error State (Mocked)
*
* Tests error handling with forced API failure.
*/
export const ErrorState = () => (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<OperatorInfoRequestBlock portalContainer={portalContainer} />
</div>
)}
</DialogStoryContainer>
);
ErrorState.parameters = {
msw: {
handlers: [
http.post('/api/tokushoho-disclosure', async () => {
await delay(1000);
return HttpResponse.json(
{
success: false,
error: 'rate-limited',
},
{ status: 429 },
);
}),
],
},
};
/**
* Loading State
*
* Extended loading time to demonstrate loading spinner.
*/
export const LoadingState = () => (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<OperatorInfoRequestBlock portalContainer={portalContainer} />
</div>
)}
</DialogStoryContainer>
);
LoadingState.parameters = {
msw: {
handlers: [
http.post('/api/tokushoho-disclosure', async () => {
await delay(5000);
return HttpResponse.json({ success: true });
}),
],
},
};
/**
* Always Open (embedded for visual testing)
*
* Block that starts with the dialog in open state, rendered within the story container.
*/
export const AlwaysOpen = () => (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<OperatorInfoRequestBlock portalContainer={portalContainer} defaultOpen />
</div>
)}
</DialogStoryContainer>
);
AlwaysOpen.parameters = {
msw: {
handlers: [
http.post('/api/tokushoho-disclosure', async () => {
await delay(500);
return HttpResponse.json({ success: true });
}),
],
},
};