Takazudo Modular Styleguide

Notify/ReservationDialog

Default
Without Product Name
Success With Reservation Id
Error State
Validation Error Missing Name
Loading State
Always Open
Long Product Name

Component Source

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;

Story Source

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"
  />
);