Takazudo Modular Styleguide

Notify/OperatorInfoRequestBlock

Default
Error State
Loading State
Always Open

Component Source

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;

Story Source

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 });
      }),
    ],
  },
};