社内の朝カフェにモバイルオーダーを導入した話

2024/04/16に公開

株式会社マイベストのエンジニア2年目になりましたkatakyoです!
mybest BlogKaigi 2024 2日目を担当します!

朝カフェとは?

マイベスト社員同士の交流、情報交換の場、出勤前の憩いの場として始まった社員持ち回りで行う社員のイベントです。20万円ほどするデロンギの全自動コーヒーメーカーなどを使って定時前の社員が日々交代でカフェの店長を行います!(なお、コーヒー自体は無料で提供されます)

部署によっては、検証で使用した最新の家電を試せたり、占いを行ったりと、イベント的な取り組みをしている部署もあります。

プロダクト開発部として、今回やるにあたり、カフェ店員が結構忙しいという話を店長をやった方から聞いたのと、何かできることないかなと考えて今回朝カフェに「モバイルオーダーシステム」を導入してみることにしました。

作ったもの

Webサイトから飲み物をオーダーし、オーダーされたものが自動でdashboardに表示されるようなシステムを実装しました!

仕様検討

飲み物の種類は5種類で、通勤途中の社員の方が電車の中や自分のデスクから注文できるようにするという状況を想定して必要な機能を洗い出しました。
ユーザー側の立場としては、「メニュー表がみれる」「飲み物が注文できる」この機能があれば最低限機能としては良さそうです。
店員側の立場としては、「注文された商品がわかること」、「いつ注文されたか」「誰に注文されたか」「提供されたかどうかのフラグ」がわかれば良さそうです。
また、今回はマネタイズは考えず、あくまで社内イベントのための機能開発という位置づけにしました。そのため、インフラコストをなるべく抑えるために、無料で利用できるサービスを選ぶという制約を設けました。

実際に利用した技術

  • Next.js (14.1)
  • Node.js(20.11.0)
  • pnpm(8.15.4)
  • Supabase
  • YamadaUI/Tailwind CSS
  • ESLint
  • Prettier
  • Vercel(デプロイ先)

MVPでMUSTではない要件を削ぎ落とす

店長をやることになってから実際にやるまで1週間もなく、開発に充てられる時間はほぼ2日間くらいしかなかったです。そのため、不要な機能をいかに削ぎ落とすかを考えました。

デザイン

広報用とトップページ、favicon用にロゴだけは作りたいと思いましたが、その他はコンポーネントのデザインなどはせずにすでにあるUIライブラリを使って工数を落とします。最近日本のfrontend界隈で話題になっていたYamadaUIを試してみたさもあったので今回はYamadaUIをUIライブラリとして採用しました。

ログイン機能

誰が、何を注文したか情報を保持するためにはUserテーブルなどを作ってログイン機能を作る必要もありましたが、社員のメールアドレスやパスワードを個人アプリのDBに保存させたくないのと、多少工数がかかりそう、使う社員が初回にログインするという手間がかかることを考慮して、今回は注文時にニックネームを送信してもらう方式にしました。

バックエンド実装

今回、バックエンドのロジックを複雑にする必要もなかったので、BaaSを使いバックエンド側はそもそも作らないという選択をしました。今回BaaSとしてSupabaseを採用しました。
他にもFirebaseなどの選択肢もありましたが、自分が普段MySQLを使っているためRDBのテーブル構造に慣れているのもあり、Supabaseを採用しています。
また、実際にカフェ店員として注文状況を把握するために、営業時間内に注文されたか把握するためのdatetime型のカラムや提供されたかどうかのカラムをテーブルに追加します。

ロゴをデザイン

ロゴのイメージはDALL・Eで参考となるようなイメージをいくつかもらいそれをillustratorで作成しました。
カフェの名前はHack Cafeと決まっていたので、エンジニアっぽいデザインをいくつかもらったところ店名のところにキーボードをいれるというイメージ画像が出てきたのでそれをillustratorで直しました。

実際に参考した画像

実際のトップページ

実装

ヘッダーナビゲーションのコンポーネントとトップ画面、注文画面、注文完了画面、注文リストの4つの画面を作ります。その後、Next.jsをSupabaseに連携してCRUD処理を行えるようにします。

テーブル設計

誰が何を注文したか、現在の注文状況はどうなっているかを確認するためにOrdersテーブルを作成しました。
本来はUsersテーブルも作成する必要もありそうですが、今回認証をしないということなのでOrdersテーブル内にnicknameというカラムを作成し、フォーム送信時にニックネームを入力してもらうことで誰が注文したか見分けるような仕様にしました。

カラム名 備考
id integer オートインクリメント
nickname string 注文者のニックネーム
drink string 注文された飲み物
status integer 注文状態(0: 注文受付中, 1: 提供済み)
created_at datetime 注文が作成された日時
updated_at datetime 注文が最後に更新された日時

Supabase

今回、Next.jsのapp routerを使いこのWebアプリケーションとSupabaseをAPI keyを用いて繋ぎこみます。Next.jsを採用した理由としてはSupabaseと相性が良かった点、Vercelを用いてデプロイが容易な点、Hooksを使いたかったなどの理由があります。

https://supabase.com/docs/guides/auth/server-side/nextjs

utils/supabase/supabase.ts
import { createClient } from "@supabase/supabase-js";
import { Database } from "@/types/supabasetype";

export const supabase = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

マイグレーション時に以下のファイルが自動生成されます

types/supabasetype/ts
export type Json =
  | string
  | number
  | boolean
  | null
  | { [key: string]: Json | undefined }
  | Json[]

export type Database = {
  public: {
    Tables: {
      Orders: {
        Row: {
          created_at: string
          drink: string | null
          id: number
          nickname: string | null
          status: number | null
          updated_at: string | null
        }
        Insert: {
          created_at?: string
          drink?: string | null
          id?: number
          nickname?: string | null
          status?: number | null
          updated_at?: string | null
        }
        Update: {
          created_at?: string
          drink?: string | null
          id?: number
          nickname?: string | null
          status?: number | null
          updated_at?: string | null
        }
        Relationships: []
      }
    }
    Views: {
      [_ in never]: never
    }
    Functions: {
      [_ in never]: never
    }
    Enums: {
      [_ in never]: never
    }
    CompositeTypes: {
      [_ in never]: never
    }
  }
}

YamadaUI

UIライブラリの候補はいくつかありますが、今回はYamadaUIを採用しました。
https://yamada-ui.com/ja/getting-started
YamadaUI自体は必要なコンポーネントをimportをしてきて使うだけなので難易度は高くないですが、2点ハマりポイントがありました。

デフォルトのカラーテーマを変える

今回App routerでプロジェクトを作成したのですが、デフォルトがSSRになってしまうため、extendThemeがうまく動かず、ドキュメント通りにテーマをカスタマイズするとうまくできませんでした。
以下の記事が参考になりました。
https://zenn.dev/illionillion/articles/github-zenn-linkage-20240204

当初はダークモードなども検討しましたが、Mustの機能でもなさそうなので最終的にはテーマのカスタマイズは使いませんでした。

ReactHookFormを利用したフォーム送信機能の実装

YamadaUIのドキュメントには一部ReactHookFormのライブラリの仕様を前提としたものがありました。
https://react-hook-form.com/
今回飲み物のオーダーは一つにして送信したかったため、別でReactHookFormのライブラリを追加し、Form送信機能をつけました。

実際のフォーム送信画面の実装

src/app/order/page.tsx
"use client";
import { supabase } from "@/utils/supabase/supabase";
import type { SubmitHandler } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import {
  Heading,
  Container,
  Radio,
  RadioGroup,
  VStack,
  FormControl,
  Center,
  Button,
  Box,
  Input,
} from "@yamada-ui/react";

export default function Order() {
  const router = useRouter();
  type Data = {
    drink: string;
    nickname: string;
  };
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<Data>();
  const onSubmit: SubmitHandler<Data> = async (data) => {
    try {
      const { error } = await supabase.from("Orders").insert([
        {
          nickname: data.nickname,
          drink: data.drink,
        },
      ]);

      if (error) throw error;

      router.push("/ordered");
    } catch (error) {
      console.error("Error submitting order:", error);
    }
  };

  return (
    <Container color="white" backgroundColor="black">
      <Center>
        <Heading>モバイルオーダー</Heading>
      </Center>
      <VStack as="form" onSubmit={handleSubmit(onSubmit)}>
        <Center>
          <Box>
            <VStack>
              <FormControl
                isRequired
                label="ニックネーム"
                helperMessage="ニックネームを入力してください"
                errorMessage={errors.nickname?.message}
              >
                <Controller
                  name="nickname"
                  control={control}
                  rules={{ required: "ニックネームは必須です" }}
                  render={({ field }) => (
                    <Input {...field} placeholder="山田太郎" />
                  )}
                />
              </FormControl>
              <FormControl
                isRequired
                isInvalid={!!errors.drink}
                label="ご注文のドリンクはどれにしますか?"
                errorMessage={errors.drink?.message}
              >
                <Controller
                  name="drink"
                  control={control}
                  rules={{
                    required: {
                      value: true,
                      message: "This is required.",
                    },
                  }}
                  render={({ field }) => (
                    <RadioGroup {...field}>
                      <Radio colorScheme="red" value="ホットコーヒー">
                        ホットコーヒー
                      </Radio>
                      <Radio colorScheme="red" value="アイスコーヒー">
                        アイスコーヒー
                      </Radio>
                      <Radio colorScheme="red" value="ホットカフェラテ">
                        ホットカフェラテ
                      </Radio>
                      <Radio colorScheme="red" value="アイスカフェラテ">
                        アイスカフェラテ
                      </Radio>
                      <Radio colorScheme="red" value="ホットティー">
                        ホットティー
                      </Radio>
                    </RadioGroup>
                  )}
                />
              </FormControl>
            </VStack>
          </Box>
        </Center>

        <Center>
          <Button colorScheme="blue" type="submit">
            注文する
          </Button>
        </Center>
      </VStack>
    </Container>
  );
}

注文リストが画面に出てくるようにする

ユーザーが注文した際に、注文リストが自動で出てくるような画面を作りました。
この際、普通に実装するとReloadを押さないとRefetch処理が走らないため、useEffectsで5sおきにRefetch処理を走らせるといった実装を加えています。またステータスの更新ボタンを画面上に置き、提供したら注文リストから消えるような実装にしました。

src/app/dashboard/page.tsx
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/utils/supabase/supabase";

import {
  Heading,
  Container,
  Card,
  CardBody,
  VStack,
  Button,
  Flex,
  Text,
} from "@yamada-ui/react";

interface Order {
  id: number;
  drink: string | null;
  nickname: string | null;
  created_at: string;
  status: number | null;
  updated_at: string | null;
}

export default function DashBoard() {
  const [orders, setOrders] = useState<Order[]>([]);

  async function getData() {
    const { data, error } = await supabase
      .from("Orders")
      .select("*")
      .eq("status", 0); // statusが0(提供前)のデータのみ取得

    if (error) {
      console.error("データの取得に失敗しました", error);
    } else {
      setOrders(data);
    }
  }

  async function updateOrderStatus(id: number) {
    const { data, error } = await supabase
      .from("Orders")
      .update({ status: 1 })
      .eq("id", id);

    if (error) {
      console.error("ステータスの更新に失敗しました", error);
    } else {
      console.log("注文ステータスを更新しました", data);
      getData();
    }
  }

  useEffect(() => {
    const interval = setInterval(() => {
      getData();
    }, 5000);

    return () => clearInterval(interval);
  }, []);

  return (
    <Container color="white" backgroundColor="black">
      <Heading>注文リスト</Heading>
      <Container>
        {orders.map((order, index) => (
          <Card key={index} color="black" backgroundColor="gray.100">
            <Flex>
              <CardBody>
                <VStack>
                  <Text>注文者: {order.nickname}</Text>
                  <Text>ドリンク: {order.drink}</Text>
                </VStack>
              </CardBody>
              <Button
                colorScheme="secondary"
                size="md"
                onClick={() => updateOrderStatus(order.id)}
              >
                提供済みにする
              </Button>
            </Flex>
          </Card>
        ))}
      </Container>
    </Container>
  );
}

実際の反応

普段の朝カフェ利用者は20-30人くらいでしたが、この日は50人以上きていただきました!

また、ご利用者の大半がモバイルオーダーを活用してくださったとのことで、システム導入の目的であった利便性の向上に一定の効果があったと実感しています。

https://x.com/sachii1015/status/1767347788252405895
https://x.com/shinagawatomoya/status/1767351298272510086

一方で、想定以上の注文数に対して課題が浮き彫りになった部分もあり、継続的な改善の必要性を感じました。
いただいた反響を糧に、より使いやすく、効率的なシステムを目指して開発を進めていきたいと思います。

反省点

実際のFBやオペレーション時にいくつか反省がありました。

  • 現状のモバイルオーダーの仕様だと完全にオペレーション管理ができなかった。
  • マイカップを持ってくる人や飲み物のサイズを考慮できていなかった。
  • 注文が殺到した時に現状のステータスで作業中のステータスを設けるべきだった。
  • モバイルオーダーの存在を知らない社員がその場で使えるようにQRコードを画面に配置しておくべきだった。
  • 来店人数に対してカフェ店員の数がピーク時に足りないことがわかった。

特にコーヒーのオペレーションに関してはソフトウェア以外に単純にリソース不足な要素もあったので、開発以外にもサービスを作る上で考慮に入れることはたくさんあると学びました。

まとめ

今回、ハッカソンのような形で個人アプリを社員の方に使ってもらうような感じになりましたが、自分自身の勉強としても社員交流的な意味でも以上に意味のある開発になりました。このような形で社内でのアプリ開発として例えばクイズアプリなど個人でサクッと作れそうなものはたくさんあるのかなと思うので興味のある方はぜひやってみてください!

Discussion