🎂

【Next.js】子供の誕生日会管理アプリを4週間で作った

に公開

はじめに

こんにちは。UIデザイナーのかきまるです。現在は、バンクーバーにてWeb developmentの専門学校でCo-op留学をしています!

この記事では、私が学校の最終プロジェクトで開発したWebサービスについて、制作プロセスやチーム開発での学びを振り返りながらご紹介します。

これから0→1のWebサービス制作やハッカソンなどにチャレンジしたい方の参考になれば嬉しいです!

プロジェクト概要

チーム構成

4名

  • バックエンド2名(うち1名PM)
  • フロントエンド・デザイン2名

PMの方はWeb系のバックエンド経験者、もう一人のバックエンドとフロントエンドの2名は、組み込み系のエンジニア経験があり、私はマークアップのみ経験があるようなチームでした。

期間

4週間

実装期間は4週間、その後の2週間でデモプレゼンの準備を行いました。

担当箇所

  • UI/UX設計、ワイヤーフレーム、プロトタイプ、デザイン作成、デザインレビュー
  • フロントエンド開発

技術スタック

  • フロントエンド
    • Next.js
    • TypeScript
    • TaiwindCSS
    • Shadcn/ui
  • バックエンド
    • Node.js
    • Express.js
  • データベース
    • PostgreSQL
    • Prisma
  • 認証・外部サービス
    • Clerk(認証)
    • Cloudinary(画像)
    • Resend(メール)
  • 使用ライブラリ・その他
    • Motion(アニメーション)
    • Axios(API通信)
    • react-slick(スライダー)
    • react-medium-image-zoom(画像の拡大)

制作したサービス

Oiwai(イベントマネジメントサービス)

  • 子供の誕生会を開く際に必要な予算や参加者の管理が簡単にできる
  • 参加者も情報が1箇所で確認できて便利

制作の流れとプロセス

1. アイデア出しと要件定義

作ってみたいものをアイデア出し

チームメンバーの中に、2歳のお子さんがいる方がいて他の案よりも課題が身近だったので子供関連のサービスにしようと決まりました。自分たちがユーザーになりうるものという点で良い選択だったと思います。

要件定義・プロジェクトの定義

これから作るものは何を解決するのかを簡潔に定義しました。
デザイナーお馴染みのDon Normanさんのサイトの記事「Problem Statements in UX Discovery」が参考になります📝


Problem Statementsを元に、MVPとして必要になる機能を洗い出し、ユーザーストーリーを決めていきました。

ユーザーストーリー作り

イベントホスト側とゲスト側でそれぞれのストーリーを作成しました。1名がベースを作り、他のメンバー全員で確認して合意をとる形で進めました。MVPが網羅されるような形で作成しています。

2. ワイヤーフレーム・デザイン制作

ワイヤーフレーム作成(Low-fidelity)

必要になる画面を洗い出し、それぞれでどの要素のアクションができるようにするのかを決めました。(私はinvitation flowを担当)

デザイン作成(High-fidelity mockups)

ワイヤーでチームメンバーの合意が取れた後、デザインを作成しました。
今回のプロジェクトでは、私以外にもう1名デザイナーに興味があるフロント担当の子がいたので、メインのデザインはお任せして私は主にレビューやFigmaの操作方法などを教えました。

デザイン作成は以下の手順で行いました。

  1. イメージボードを一緒に作る
  2. メインの色を決めてもらう
  3. 参考デザインを集める
  4. 1画面だけ2人でそれぞれ作って方向性を確認し、角丸のサイズや色味の割合を確認
  5. 合意が取れたら本格的な作業に入り、出来次第お互いにレビューをする


発表まで開発可能な期間が4週間しかなかったため、カラートークンと余白・文字サイズを8の倍数で使うことだけを決めてスムーズに実装できるように進めました。

👇 デザインのプロトタイプです!
プロトタイプリンク

  • Landing Page~hostの流れ
  • Guestが招待状を受け取る〜承諾
  • Guestのイベントページ
  • イベント後のレビュー
    で4つのフローがあります!
ファイルまたはプロトタイプのFigma URLを指定してください

3. ER図とロードマップ作成

ER図を作成

バックエンド担当メンバーが作成し、デザインと合わせて一緒に仕様を確認しました。(drow.ioを使用)

ロードマップを作成しタスク分配

PMが画面ベースで作成したロードマップから担当者を決めて実装に着手できる状態になりました!
授業中の4h/1日をベースに工数を見積り、githubのプロジェクトを使って管理しました。

4. 実装着手

プロジェクト全体

フロントエンドがマークアップを進めている間に、バックエンドがAPIドキュメント(mdファイルで作成し、PRで確認でき次第githubのwikiに載せる運用でした)を作成し、バックエンドの実装が終わったらフロントと統合していきました。PRはレビューはPMと同じ担当のメンバー(フロントならフロントメンバー)に依頼し、OKだったらマージしていました。
また、学校のプロジェクトではあったものの、実際の仕事のような形で毎朝30min程度の定例で進捗管理を行いました。昨日やったこと、今日やること、詰まっていることを共有します。

フロントエンド開発

ディレクトリ構成、コンポーネントの分割基準や命名規則を最初に決めてから実装を始めました。
Tailwind CSS と shadcn/ui を使って開発を進めたため、クラスの並び順や記述スタイルを統一する目的prettier-plugin-tailwindcssを導入しました。
これにより、結果的にコミュニケーションコストの削減にもつながりました。
- 可読性の高いコードを維持できた
- 複数人での開発でもスタイルがブレにくくなった

src/
  ├── app/
  │   ├── page.tsx                   # ルートページ(ログインの有無を判定し各ページにリダイレクト)
  │   ├── layout.tsx                 # ルートレイアウト
  │   ├── not-found.tsx              # 404ページ
  │   ├── (auth)/                    # 認証が必要なルート
  │   │   ├── my-page/
  │   │   │   └── page.tsx           # マイページ
  │   │   ├── event/
  │   │   │   └── [eventId]/        
  │   │   │      ├── page.tsx.      # eventページ(event-home)
  │   │   │      ├── album/         # アルバムページ
  |   |   |  
  │   │   └── not-found.tsx          # 認証済みユーザー用404 (必要に応じて)
  │   │
  │   ├── (public)/                  # 認証不要のルート
  │   │   ├── page.tsx               # ランディングページ
  │   │   └── not-found.tsx          # 未認証ユーザー用404  (必要に応じて)
  │   │
  │   └── api/                       # APIルート
  │       └── [route]/
  │           └── route.ts
  ├── components/
  │   ├── ui/                     # shadcn/uiコンポーネント
  │   │
  │   ├── features/               # 機能別コンポーネント
  │	  │   ├── modal.tsx              # 共通モーダルコンポーネント
  │	  │   ├── rsvp/.                 # 参加可否関連
  │   │   ├── event/          # 画面ごと
  │   │   │   ├── announce/       # アナウンス機能関連
  │   │   │   │   ├── announce-card.tsx
  │   │   │   │   └── announce-reply.tsx
  │   │   │   │
  │   │   │   ├── timeline/          # タイムライン機能関連
  │   │   │   ├── album/             # アルバム機能関連
  │   │   │   └── common/            # イベントページで共通するコンポーネント
  │   │   └── mypage/
  │   │       ├── profile.tsx        # プロフィール関連
  │   │       ├── family.tsx         # 家族関連
  │   │       │   ├── family-card.tsx 
  │   │       │   └── family-edit-modal.tsx
  │   │       │   └── family-delete-modal.tsx
  │   │       └── events/            # イベント関連
  │   │           └── event-card.tsx
  │   │
  │   └── layouts/                   # レイアウトコンポーネント
  │       ├── header.tsx
  │       └── footer.tsx
  │
  ├── lib/
      ├── actions/                   # Server Actions
          ├── event/
              ├── album.ts           # 画面ごとに分ける
              ├── timeline.ts
          ├── announcement/
              ├── announcement.ts
          ├── mypage/
              └── myPage.ts
      ├── api/                       # API関連
      │   └── axios.ts
      └── utils/                     # ユーティリティ関数
  │
  ├── hooks/                         # カスタムフック
  │
  ├── types/                         # 型定義 // 画面ごとに分ける
  │   ├── event.d.ts
  │   └── mypage.d.ts
  │
  ├── constants/                     # 定数
  │   └── config.ts
  │
  ├── globals.css                    # グローバルスタイル
  │
  ├── store/                         # グローバル変数管理(Zustand store)
  └── public/                        # 静的ファイル
      ├── images/
      └── icons/
- コンポーネント: kebab-case (例: my-page/page.tsx)
- Props: GuestInformationFormProps
- 一般関数: camelCase (例: formatDate.ts)
- カスタム変数(Tailwindのカスタムなど)、typeファイル : kebab-case(例: background-light)
- 定数: SCREAMING_SNAKE_CASE (例: API_ENDPOINTS.ts)
- ページ: page.tsx
- レイアウト: layout.tsx
- ローディング: loading.tsx
- エラー: error.tsx
- 404: not-found.tsx
- URL: kebab-case(例: my-page)

担当箇所

  • LP

https://youtube.com/shorts/h2jcQtNtgwE?feature=share

  • マイページ

https://youtube.com/shorts/vYRRbGDsZpE?feature=share

  • 必要な持ち物リスト(ホスト)

https://youtube.com/shorts/dOF5xz0uCkY?feature=share

  • ゲストの出席確認、招待状への反応リスト、アルバム

https://youtube.com/shorts/Mwy4hYjzpwQ?feature=share

  • ゲスト側のイベントページ見え方

https://youtube.com/shorts/WgxaLtn8-ec?feature=share

  • レビュー

https://youtube.com/shorts/BaTcvZB33OE?feature=share

大変だったこと

コンポーネントの共通化


モーダルを使い回す形で実装するのにとても苦労しました。
添付のようなモーダルがあり、当初はすべてのパターンを1つのコンポーネントにまとめようとしていました。メンバーに相談し、大まかにはボタンのみのものと、インプットなどの要素が含まれるものの2種類があるので2つのモーダルコンポーネントを作る形が早いかもしれないとアドバイスをもらいました。

2種類でもかなり混乱したので、良い選択だったと思います。インプット要素がある部分はプロップスに合わせて実行する関数を変えたかったのですが、分岐が多くなってしまってこれで良い方法なのか自信がありません;;

最終的なコード👇

frontend/src/components/features/modal.tsx
"use client";

type ModalProps = {
  trigger: ReactNode;
  title: string;
  description?: string;
  button?: ReactNode;
  deleteAction: (id?: string) => Promise<{ success: boolean; message: string }>;
  id?: string;
  deleteErrorMessage: string;
  onSuccess?: () => void;
};

export default function Modal({
  trigger,
  title,
  description,
  button,
  deleteAction,
  id,
  deleteErrorMessage,
  onSuccess,
}: ModalProps) {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isOpen, setIsOpen] = useState<boolean>(false);

  const { toast } = useToast();

  const handleDelete = async (e: FormEvent) => {
    e.preventDefault();
    setIsLoading(true);

    try {
      if (id) {
        const response = await deleteAction(id);
        if (response.success) {
          setIsOpen(false);
          if (onSuccess) {
            onSuccess();
          }
        }
        return;
      }

      if (!id && deleteAction) {
        const response = await deleteAction();
        if (response.success) {
          setIsOpen(false);
          return;
        }
      }

      throw new Error("Invalid ID and no delete action available.");
    } catch (error) {
      if (error instanceof Error) {
        showErrorToast(toast, error.message, deleteErrorMessage);
      } else {
        showErrorToast(toast, "Failed to delete user", deleteErrorMessage);
      }
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogTrigger asChild>{trigger}</DialogTrigger>
      <DialogContent className="bg-white">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          {description && <DialogDescription>{description}</DialogDescription>}
        </DialogHeader>
        <DialogFooter className="flex flex-row justify-between gap-4">
          <DialogClose asChild>
            <Button type="button" variant="outline" className="w-full bg-white">
              Cancel
            </Button>
          </DialogClose>
          <Button
            type="submit"
            className="w-full bg-error font-bold shadow-none hover:bg-error/70 focus:bg-error active:bg-error"
            onClick={handleDelete}
          >
            {isLoading ? <Loader /> : button}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
frontend/src/components/features/person-modal.tsx
"use client";

type PersonModalProps = {
  trigger?: ReactNode;
  title: string;
  defaultName?: string;
  defaultImage?: string;
  type: "user" | "family" | "guest";
  mode?: "new" | "edit";
  familyId?: string;
  eventId?: string;
  errorMessage: string;
  onSuccess?: () => void;
};

export default function PersonModal({
  trigger,
  title,
  defaultName,
  defaultImage = "/images/profile_default.png",
  type,
  mode,
  familyId,
  eventId,
  errorMessage,
  onSuccess,
}: PersonModalProps) {
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [imageUrl, setImageUrl] = useState<string>(defaultImage);
  const [imageUrlData, setImageUrlData] = useState<File | null>(null);
  const [name, setName] = useState<string>(defaultName || "");
  const [isOpen, setIsOpen] = useState<boolean>(false);

  const inputImageRef = useRef<HTMLInputElement>(null!);
  const { toast } = useToast();

  const defaultImagePath = "/images/profile_default.png";

  useEffect(() => {
    if (isOpen) {
      setName(defaultName || "");
      setImageUrl(defaultImage);
    }
  }, [isOpen, defaultName, defaultImage]);

  // image functions
  const handleImageClick = (e: React.MouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    inputImageRef.current.click();
  };

  const revokeObjectURL = useCallback(() => {
    if (imageUrl.startsWith("blob:")) {
      URL.revokeObjectURL(imageUrl);
    }
  }, [imageUrl]);

  const handleImageDelete = (e: React.MouseEvent) => {
    e.preventDefault();
    e.stopPropagation();
    revokeObjectURL();
    setImageUrl(defaultImagePath);
    setImageUrlData(null);
  };

  const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const imageUrl = URL.createObjectURL(file);
      setImageUrlData(file);
      setImageUrl(imageUrl);
      revokeObjectURL();
    }
  };

  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  };

  const resetForm = () => {
    setName(defaultName || "");
    revokeObjectURL();
    setImageUrl(defaultImage);
  };

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    setIsLoading(true);

    try {
      let response;
      const updateData: { name: string; profileImageUrl?: File | null } = {
        name,
      };

      if (imageUrlData) {
        updateData.profileImageUrl = imageUrlData;
      } else if (imageUrlData === defaultImagePath) {
        updateData.profileImageUrl = null;
      }

      if (type === "user") {
        response = await updateUserInfo(updateData);
      } else if (familyId && type === "family") {
        response = await updateFamilyInfo({
          familyId,
          ...updateData,
        });
      } else if (eventId && type === "guest") {
        response = await addTemporaryParticipant(eventId, name);
      }

      if (mode === "new") {
        response = await addFamilyMember(updateData);
      }

      if (!response?.success) {
        notFound();
      }

      setIsOpen(false);
      resetForm();

      if (onSuccess) {
        onSuccess();
      }
    } catch (err: unknown) {
      if (err instanceof Error) {
        showErrorToast(toast, err.message, errorMessage);
      } else {
        showErrorToast(toast, "Unknown error occurred", errorMessage);
      }
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    return () => {
      revokeObjectURL();
    };
  }, [revokeObjectURL, imageUrl]);

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogTrigger asChild>
        {trigger ? (
          trigger
        ) : (
          <Button className="h-8 w-8 rounded-full bg-textSub/20 text-textSub shadow-none hover:bg-textSub/20 hover:opacity-70">
            <PencilLineIcon />
          </Button>
        )}
      </DialogTrigger>
      <DialogContent
        className="gap-6 bg-white"
        onClick={(e) => e.stopPropagation()}
      >
        <DialogHeader>
          <DialogTitle className="text-left">{title}</DialogTitle>
        </DialogHeader>
        <form onSubmit={handleSubmit} className="grid gap-6">
          <div className="grid justify-items-center gap-4">
            {type !== "guest" && (
              <>
                <div className="relative">
                  <button
                    type="button"
                    className="absolute -right-8"
                    onClick={handleImageDelete}
                  >
                    <X size={14} />
                  </button>
                  <button type="button" onClick={handleImageClick}>
                    <Image
                      src={imageUrl}
                      alt={name}
                      className="h-16 w-16 rounded-full object-cover"
                      width={64}
                      height={64}
                    />
                  </button>
                </div>
                <input
                  type="file"
                  id="image"
                  name="image"
                  onChange={handleImageChange}
                  hidden
                  ref={inputImageRef}
                />
              </>
            )}
            <div className="grid w-full gap-2">
              <Label htmlFor="name">Name</Label>
              <Input
                id="name"
                name="name"
                required
                onChange={handleNameChange}
                value={name}
              />
            </div>
          </div>

          <DialogFooter className="flex flex-row justify-between gap-4">
            <DialogClose asChild>
              <Button
                type="button"
                variant="outline"
                className="w-full bg-white"
              >
                Cancel
              </Button>
            </DialogClose>
            <Button type="submit" className="w-full font-bold shadow-none">
              {isLoading
                ? "Updating..."
                : mode === "new"
                  ? "Add"
                  : mode === "edit"
                    ? "Update"
                    : "Submit"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}

APIドキュメントの確認


後半は慣れましたが、最初はResponseだけ確認しドキュメントをきちんと読み込んでいませんでした。
例えば、添付画像のRequestBodyのタイプはContent-Type: multipart/form-dataであると書いてあるのに、見落として時間を溶かしてしまうこともありました。
画像は送るデータが文字や数字と異なることや、inputして取得したデータを画面上ですぐに表示させる方法など学べたのでよかったです。

名前付け

前職でマークアップをしていた時に、親要素に対してContainerやWrapなどのクラス名をつけていたのですが、コンポーネントにも〇〇Containerなどと命名していたところ、Containerは抽象的/名前付けのバリエーションが偏っているとレビューをもらいました。また、ParticipantListItemとParticipantItemとなど似ている名前付けも多いので気をつけたほうが良いとアドバイスをいただきました。
初めてコードを見た人もわかるようにという視点がまだまだ足りていないなと感じるので、ここは今後身につけていきたいです!

スクロール位置やshadcnのformコンポーネント利用

一つひとつ詰まりながらも試行錯誤して進めました。
特に苦戦したのはshadcn/uiのformコンポーネントです。zodを使ったバリデーションの記述が難しく型エラーへの対応に時間がかかりました。

また、Next.jsのserver actionsを使ったフォーム送信とどちらを使うべきかも迷いました。要素が多い場合は実際の現場や歴の長いエンジニアさんはどのように判断しているのか気になります。

さらに、パンくずリストやリンクから前のページに遷移した際に、スクロール位置が保持されたままになり、要素が見切れてしまうことへの対応も難しかったです。
記事などを参考にして scroll={true} を試してみましたがうまくいかず、最終的には該当ページでスクロール位置をリセットする関数を自作して対応しました。ただ、このアプローチがベストなのかはまだ自信がないので、どの方法がベストプラクティスなのか知りたいです👀

scroll-top-top.tsx
"use client";

import { useEffect } from "react";

export default function ScrollToTop() {
  useEffect(() => {
    window.scrollTo(0, 0);
  }, []);
  return null;
}

得られた学び

Next.jsとNode.jsを用いたプロジェクト作成の流れを学ぶことができました。バックエンド側は授業でやったものの、今回は書かれたコードを少し見た程度であまり理解できていないので、自分で小さいものを作って練習したいです!
また、当たり前ですがデザインを作るときと、実装するときに考えることもかなり違うなあと感じました。デザインの場合はユーザーの動きを想定しながらオブジェクトを整理し設計していましたが、実装時はエンドポイントから返す値や取得方法を想定するため、より具体的にする必要があります。私がこれまで足りていなかった部分で、フロントを勉強したいと思った理由でもあります。

ちなみに、もう一人のデザインにも興味があるフロントエンドのメンバーは、元々のエンジニア経験からか、デザイン時からAPIがどうなるのかを考えていて素敵でした。
素早くデザインをする必要がある時に、画面の整理/使いやすさ/見た目の表現などの考慮にプラスして、データの流れも入れていけたら理想です。

普段お世話になっているデザイナーの方々は実装時をある程度想定しながらデザイナーとしての理想系を提案していらっしゃっていて、そのバランスを目指したいなと思います!

また、今回はカラートークンのみでしたが、storybookでのUI管理や最近話題のMCPやFigma→cursorでのコンポーネント実装など、デザインとフロントの領域も勉強したいなと改めて思いました🙌

リンク

Github: https://github.com/kakimaru/oiwai
Figma: https://www.figma.com/design/1l3niGh4ZAzR5rDv2SCi8X/Oiwai?node-id=70-5925&t=MN61YXeTtCPcNe7W-1

さいごに

このプロジェクトを通じて、デザイナーとしての引き出しが一気に増えたと感じています。
将来的には、プロダクト全体に関わるデザインエンジニアとして成長したいと思っており、今後も積極的にチームでの開発に挑戦していきます!頑張るぞ!

ハッカソンなどで0からプロジェクトを作る予定の方の参考になっていれば幸いです!

Discussion