Notify/ReservationDialog
Default
Without Product Name
Success With Reservation Id
Error State
Validation Error Missing Name
Loading State
Always Open
Long Product Name
reservation-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, ReservationRequest, ReservationResponse } from './types';
import type { Locale } from '@/lib/i18n/types';
interface ReservationDialogProps {
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;
}
/**
* Reservation Dialog Component
*
* Dialog for reserving a product that is coming soon.
* Fields: name and email address
*
* Features:
* - Form validation
* - Loading state during submission
* - Success/error feedback with reservation ID
* - Accessible form
*/
export const ReservationDialog = ({
isOpen,
onClose,
productSlug,
productName,
locale = 'ja',
apiEndpoint = '/api/reservation',
portalContainer,
}: ReservationDialogProps) => {
const copy = getNotifyDialogCopy(locale);
const dialogCopy = copy.reservationDialog;
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [formState, setFormState] = useState<FormState>({ status: 'idle' });
const handleSubmit = useCallback(
async (e: TargetedEvent<HTMLFormElement>) => {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
setFormState({ status: 'submitting' });
try {
const requestBody: ReservationRequest = {
name: name.trim(),
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: ReservationResponse = await response.json();
if (data.success) {
setFormState({ status: 'success' });
setName('');
setEmail('');
} else {
setFormState({
status: 'error',
errorMessage: data.error || data.message || dialogCopy.failError,
});
}
} catch {
setFormState({
status: 'error',
errorMessage: copy.shared.networkError,
});
}
},
[name, email, productSlug, apiEndpoint, copy, dialogCopy],
);
const handleClose = useCallback(() => {
// Reset form state when closing
setFormState({ status: 'idle' });
setName('');
setEmail('');
onClose();
}, [onClose]);
const isSubmitting = formState.status === 'submitting';
const isSuccess = formState.status === 'success';
const isError = formState.status === 'error';
const isFormValid = name.trim() && email.trim();
return (
<BaseDialog
isOpen={isOpen}
onClose={handleClose}
title={dialogCopy.title}
ariaDescribedBy="reservation-description"
closeLabel={copy.shared.close}
portalContainer={portalContainer}
>
<div id="reservation-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>
)}
{dialogCopy.flowNote && (
<p className="pt-vgap-xs text-sm text-zd-subtext">
{dialogCopy.flowNote.before}
<a href={dialogCopy.flowNote.href} className="zd-invert-color-link">
{dialogCopy.flowNote.linkLabel}
</a>
{dialogCopy.flowNote.after}
</p>
)}
</div>
{isSuccess ? (
<StatusMessage type="success">
<Lines lines={dialogCopy.successLines} />
</StatusMessage>
) : (
<form onSubmit={handleSubmit}>
<FormInput
id="reservation-name"
label={dialogCopy.nameLabel}
type="text"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
placeholder={dialogCopy.namePlaceholder}
required
disabled={isSubmitting}
autoComplete="name"
autoFocus
/>
<FormInput
id="reservation-email"
label={copy.shared.emailLabel}
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder={copy.shared.emailPlaceholder}
required
disabled={isSubmitting}
autoComplete="email"
/>
{isError && (
<div className="pb-vgap-sm">
<StatusMessage type="error">{formState.errorMessage}</StatusMessage>
</div>
)}
<div className="pt-vgap-xs">
<SubmitButton
disabled={!isFormValid}
isLoading={isSubmitting}
loadingLabel={copy.shared.sending}
>
{dialogCopy.submit}
</SubmitButton>
</div>
</form>
)}
</BaseDialog>
);
};
export default ReservationDialog;
reservation-dialog.stories.tsx
import { useState } from 'preact/hooks';
import { http, HttpResponse, delay } from 'msw';
import { ReservationDialog } from './reservation-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';
/**
* ReservationDialog Component Stories
*
* Dialog for reserving products that are coming soon.
* Features:
* - Name and email fields
* - Loading state during submission
* - Success/error feedback with reservation ID
* - MSW mocked API responses
*
* All stories use embedded portal container to avoid blocking styleguide UI.
*/
export const meta = {
title: 'Notify/ReservationDialog',
};
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>
<ReservationDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
productSlug={productSlug}
productName={productName}
portalContainer={portalContainer}
/>
</div>
)}
</DialogStoryContainer>
);
};
/**
* Default State
*
* Interactive dialog with MSW mocked API.
* Fill in name and email, then submit to see success state with reservation ID.
*/
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 with Reservation ID
*
* Shows the success message including reservation ID.
* Submit the form to see the generated reservation ID.
*/
export const SuccessWithReservationId = () => (
<div>
<p className="text-zd-white pb-vgap-sm">
フォームを送信すると、予約番号付きの成功メッセージが表示されます。
</p>
<DialogWrapper productSlug="mock-incoming" productName="Sample Product" />
</div>
);
/**
* 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/reservation', async () => {
await delay(1000);
return HttpResponse.json(
{
success: false,
message: 'サーバーエラーが発生しました。もう一度お試しください。',
},
{ status: 500 },
);
}),
],
},
};
/**
* Validation Error - Missing Name
*
* Tests validation when name field is empty.
*/
export const ValidationErrorMissingName = () => (
<DialogWrapper productSlug="mock-incoming" productName="Validation Test" />
);
ValidationErrorMissingName.parameters = {
msw: {
handlers: [
http.post('/api/reservation', async () => {
await delay(500);
return HttpResponse.json(
{
success: false,
message: 'お名前を入力してください。',
},
{ status: 400 },
);
}),
],
},
};
/**
* 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/reservation', async () => {
// Long delay to observe loading state
await delay(5000);
return HttpResponse.json({
success: true,
reservationId: 'RSV-TEST123-ABCD',
message: 'ご予約を承りました。',
});
}),
],
},
};
/**
* Always Open (embedded for visual testing)
*
* Dialog that starts in open state, rendered within the story container.
*/
export const AlwaysOpen = () => (
<DialogStoryContainer>
{(portalContainer) => (
<ReservationDialog
isOpen={true}
onClose={() => {}}
productSlug="mock-incoming"
productName="Visual Test Product"
portalContainer={portalContainer}
/>
)}
</DialogStoryContainer>
);
/**
* Long Product Name
*
* Tests dialog with a very long product name to check text wrapping.
*/
export const LongProductName = () => (
<DialogWrapper
productSlug="mock-long-name"
productName="Very Long Product Name That Should Wrap Nicely In The Dialog Header And Description Area"
/>
);