Notify/NotifyMeDialog
Default
Without Product Name
Success State
Error State
Duplicate Email Error
Loading State
Always Open
notify-me-dialog.tsx
import type { TargetedEvent } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import { BaseDialog } from './base-dialog';
import { FormInput, SubmitButton, StatusMessage, Lines } from './form-components';
import { getNotifyDialogCopy } from './copy';
import type { FormState, NotifySignupRequest, NotifySignupResponse } from './types';
import type { Locale } from '@/lib/i18n/types';
interface NotifyMeDialogProps {
isOpen: boolean;
onClose: () => void;
productSlug: string;
productName?: string;
locale?: Locale;
/** Optional custom API endpoint for testing */
apiEndpoint?: string;
/** Optional: custom portal container for embedded rendering (e.g. Storybook) */
portalContainer?: HTMLElement | null;
}
/**
* NotifyMe Dialog Component
*
* Dialog for signing up for product restock notifications.
* Single field: email address
*
* Features:
* - Email validation
* - Loading state during submission
* - Success/error feedback
* - Accessible form
*/
export const NotifyMeDialog = ({
isOpen,
onClose,
productSlug,
productName,
locale = 'ja',
apiEndpoint = '/api/notify-signup',
portalContainer,
}: NotifyMeDialogProps) => {
const copy = getNotifyDialogCopy(locale);
const dialogCopy = copy.notifyDialog;
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 requestBody: NotifySignupRequest = {
email: email.trim(),
productSlug,
};
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (response.status >= 500) {
setFormState({
status: 'error',
errorMessage: copy.shared.serverError,
});
return;
}
const data: NotifySignupResponse = await response.json();
if (data.success) {
setFormState({ status: 'success' });
setEmail('');
} else {
setFormState({
status: 'error',
errorMessage: data.error || data.message || dialogCopy.failError,
});
}
} catch {
setFormState({
status: 'error',
errorMessage: copy.shared.networkError,
});
}
},
[email, productSlug, apiEndpoint, copy, dialogCopy],
);
const handleClose = useCallback(() => {
// Reset form state when closing
setFormState({ status: 'idle' });
setEmail('');
onClose();
}, [onClose]);
const isSubmitting = formState.status === 'submitting';
const isSuccess = formState.status === 'success';
const isError = formState.status === 'error';
return (
<BaseDialog
isOpen={isOpen}
onClose={handleClose}
title={dialogCopy.title}
ariaDescribedBy="notify-me-description"
closeLabel={copy.shared.close}
portalContainer={portalContainer}
>
<div id="notify-me-description" className="pb-vgap-sm text-zd-white">
{productName ? (
<p>
{dialogCopy.bodyBeforeName}
<span className="font-bold">{productName}</span>
{dialogCopy.bodyAfterName}
</p>
) : (
<p>{dialogCopy.bodyNoName}</p>
)}
</div>
{isSuccess ? (
<StatusMessage type="success">
<Lines lines={dialogCopy.successLines} />
</StatusMessage>
) : (
<form onSubmit={handleSubmit}>
<FormInput
id="notify-me-email"
label={copy.shared.emailLabel}
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder={copy.shared.emailPlaceholder}
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}
loadingLabel={copy.shared.sending}
>
{dialogCopy.submit}
</SubmitButton>
</div>
</form>
)}
</BaseDialog>
);
};
export default NotifyMeDialog;
notify-me-dialog.stories.tsx
import { useState } from 'preact/hooks';
import { http, HttpResponse, delay } from 'msw';
import { NotifyMeDialog } from './notify-me-dialog';
import { ActionButton } from '@/components/shared/action-button';
import { DialogStoryContainer } from '@/.storybook/dialog-story-container';
import type { ControlsMap } from '../../sub-packages/styleguide-v2/src/data/control-types';
/**
* NotifyMeDialog Component Stories
*
* Dialog for signing up for product restock notifications.
* Features:
* - Single email field
* - Loading state during submission
* - Success/error feedback
* - MSW mocked API responses
*
* All stories use embedded portal container to avoid blocking styleguide UI.
*/
export const meta = {
title: 'Notify/NotifyMeDialog',
};
export const controls: ControlsMap = {
productName: { type: 'text', default: 'Incoming Product' },
productSlug: { type: 'text', default: 'mock-incoming' },
};
/**
* Interactive wrapper to demonstrate dialog open/close behavior.
* Uses embedded portal container to keep dialog within the story area.
*/
const DialogWrapper = ({
productSlug,
productName,
buttonLabel = '入荷通知を受け取る',
}: {
productSlug: string;
productName?: string;
buttonLabel?: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<ActionButton onClick={() => setIsOpen(true)}>{buttonLabel}</ActionButton>
<NotifyMeDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
productSlug={productSlug}
productName={productName}
portalContainer={portalContainer}
/>
</div>
)}
</DialogStoryContainer>
);
};
/**
* Default State
*
* Interactive dialog with MSW mocked API.
* Try submitting with a valid email to see success state.
*/
export const Default = {
args: { productName: 'Incoming Product', productSlug: 'mock-incoming' },
render: ({ productName, productSlug }: { productName: string; productSlug: string }) => (
<DialogWrapper productSlug={productSlug} productName={productName || undefined} />
),
};
/**
* Without Product Name
*
* Dialog without specific product name shown.
*/
export const WithoutProductName = () => <DialogWrapper productSlug="mock-product" />;
/**
* Success State wrapper component
*/
const SuccessStateWrapper = () => {
const [isOpen, setIsOpen] = useState(true);
return (
<DialogStoryContainer>
{(portalContainer) => (
<div className="p-hgap-sm">
<p className="text-zd-white pb-vgap-sm">
このストーリーは成功状態をテストするためのものです。
<br />
実際にメールを入力して送信すると、成功メッセージが表示されます。
</p>
<ActionButton onClick={() => setIsOpen(true)}>ダイアログを開く</ActionButton>
<NotifyMeDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
productSlug="mock-incoming"
productName="Sample Product"
portalContainer={portalContainer}
/>
</div>
)}
</DialogStoryContainer>
);
};
/**
* Success State (Static)
*
* Pre-filled success state for visual testing.
*/
export const SuccessState = () => <SuccessStateWrapper />;
/**
* Error State (Mocked)
*
* Tests error handling with forced API failure.
*/
export const ErrorState = () => (
<DialogWrapper productSlug="mock-incoming" productName="Error Test Product" />
);
ErrorState.parameters = {
msw: {
handlers: [
http.post('/api/notify-signup', async () => {
await delay(1000);
return HttpResponse.json(
{
success: false,
message: 'サーバーエラーが発生しました。もう一度お試しください。',
},
{ status: 500 },
);
}),
],
},
};
/**
* Duplicate Email Error
*
* Tests duplicate email error scenario.
* Use "duplicate@example.com" to trigger this error.
*/
export const DuplicateEmailError = () => (
<div>
<p className="text-zd-white pb-vgap-sm">
duplicate@example.com を入力すると重複エラーが表示されます。
</p>
<DialogWrapper productSlug="mock-incoming" productName="Duplicate Test" />
</div>
);
/**
* Loading State
*
* Extended loading time to demonstrate loading spinner.
*/
export const LoadingState = () => (
<DialogWrapper productSlug="mock-incoming" productName="Loading Test Product" />
);
LoadingState.parameters = {
msw: {
handlers: [
http.post('/api/notify-signup', async () => {
// Long delay to observe loading state
await delay(5000);
return HttpResponse.json({
success: true,
message: '登録が完了しました。',
});
}),
],
},
};
/**
* Always Open (embedded for visual testing)
*
* Dialog that starts in open state, rendered within the story container.
*/
export const AlwaysOpen = () => (
<DialogStoryContainer>
{(portalContainer) => (
<NotifyMeDialog
isOpen={true}
onClose={() => {}}
productSlug="mock-incoming"
productName="Visual Test Product"
portalContainer={portalContainer}
/>
)}
</DialogStoryContainer>
);