Takazudo Modular Styleguide

Notify/NotifyMeDialog

Default
Without Product Name
Success State
Error State
Duplicate Email Error
Loading State
Always Open

Component Source

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;

Story Source

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