3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ランニングコストを抑えた小規模ECサイトをつくる (ft. Next.js+Firebase+Stripe)

Last updated at Posted at 2023-05-08

友人の頼みでサークル用のECサイトを作ったので、ポートフォリオとしても使えるようにこちらでもメモしておきます。
あまり凝っていないポートフォリオなので分かりづらいかもしれませんが、ご容赦ください。

【開発環境】

node:v-18.15.0
npm:v-9.6.4
next:v-13.3.1
stripe:v-12.2.0
firebase:v-9.21.0
firebase-admin:v-11.7.0

【はじまり】
大学のサークルでECサイトを作りたいけど、どうにかして安上がりで自前のサイトを作れないものか。
最初はshopifyを考えていましたが月額3000円もかかるとのことで、なんとか他に方法がないかと探していた時に出会ったのがstripeとfirebaseのコンビでありました。
stripeは毎回の決済手数料のみで、初期費用や月額料金はなし。firebaseも無料枠の中でFirebase Hostingで商用利用のデプロイができるとのことで、安上がりでそれらしいサイトが作れそうな雰囲気が出てきました。

【参考】
1.https://zenn.dev/stripe/books/stripe-nextjs-use-shopping-cart
stripeのコーディングはこの方のワークショップを参考にさせていただきました。開発に際しても多くのコードを流用しているため、その部分についてはこの記事の中では説明はしません。
ただ、実装に当たって所々付加・修正したところもあるのでその部分については追って説明したいと思います。
2.https://qiita.com/centerfield77/items/a7e4964d6c1963b70bbb
「SSG+SWRでISRっぽい挙動を実現しようとする」際に参考にしました。

【小規模ECサイトでの要件】
・商品一覧:stripeのダッシュボードから商品登録を行い、SSRでデータをフェッチします。
・決済機能:stripeのチェックアウトセッションを用いて簡単に実装できます。
・在庫管理:firebaseを用いて実際の在庫量、表示する在庫量、累計注文数と累計配送数を管理します。どのような値を管理したいかは案件によって異なると思いますが、ここでは上記の四つの情報を各商品ごとにデータベースに紐づけました。
・配送管理:stripeのwebhookから決済された商品の情報を読み取り、その商品ごとに決済者の住所、名前などの個人情報と、購入商品および購入量をまとめたデータセットを作り、データベースに保存します。配送完了したらデータベースでの情報を削除するようにしました。
※決済機能などはstripeが提供する決済画面へとページ遷移させる一方で、在庫・配送管理は管理者ページにおける操作が必要となります。

【フォルダツリー】

src
├─components
      administer.tsx
      cart.tsx
├─hooks
      useAuthentication.tsx
├─lib
    adminFirebase.tsx
    firebase.tsx
  └─path
      └─to
              serviceAccountKey.json
├─pages
    index.tsx
    success.tsx
    _app.tsx
    _document.tsx
  ├─api
        checkout_session.tsx
        products.tsx
        webhooks.tsx
  └─subpages
          managerpage.tsx
└─type
        AnonymousUser.tsx
        IntegratedProducts.tsx
        ManagerAccount.tsx
        OrderedCustomer.tsx
        Products.tsx

【開発】
①stripeを用いた商品情報の取得。

//products.tsx
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Array<object>|Stripe.Response<Stripe.ApiList<Stripe.Product>>>
) {
  const stripe=new Stripe(process.env.STRIPE_API_KEY,{
    apiVersion:"2022-11-15",
    maxNetworkRetries:3
  });
  const products= await stripe.products.list({
    active: true,
  });

  const response = await Promise.all(products.data.map(async (product,i) => {
    const prices = await stripe.prices.list({
      product:product.id
    });
    return{
      id:product.id,
      description:product.description,
      name:product.name,
      images:product.images,
      unit_label:product.unit_label,
      prices:prices.data.map(price=>{
        return{
          id:price.id,
          currency:price.currency,
          transform_quantity:price.transform_quantity,
          unit_amount:price.unit_amount
        }
      })
    }
  }))
  res.status(200).json(response)
}

インスタンス化したStripeオブジェクトから商品情報を得るコードです。ここで一つ注意したいのは、stripe.product.list({active:true})の部分です。Stripeのダッシュボードでは、一度取引や決済フォームなどのリンクに使用した商品を削除することができず、商品を利用しないようにするためにはアーカイブするしかないようです。アーカイブされた商品はdataのactiveプロパティがfalseになるようなので、trueのものだけ選別して商品情報の配列を渡します。

②firebaseの初期化
firebase/appを用いた初期化処理及びデータベースオブジェクト(ここではdb)は、フロント側での記述で用いることになります。

//firebase.tsx
import {initializeApp,getApps, getApp} from "firebase/app";
import "firebase/auth";
import {getAnalytics} from "firebase/analytics";
import { getFirestore } from "firebase/firestore";

const firebaseConfig={
    apiKey:process.env.FIREBASE_API_KEY ,
    authDomain:process.env.FIREBASE_AUTH_DOMAIN,
    projectId:process.env.FIREBASE_PROJECT_ID,
    storageBucket:process.env.FIREBASE_STORAGE_BUCKET,
    messagingSenderId:process.env.FIREBASE_MESSAGEING_SENDER_ID,
    appId: process.env.FIREBASE_APP_ID,
    measurementId:process.env.FIREBASE_MEASUREMENT_ID
};
export const app=!getApps().length  ? initializeApp(firebaseConfig) : getApp();
export const db=getFirestore(app)
getAnalytics(app);

firebase-adminを用いた初期化処理及びデータベースオブジェクト(ここではdatabase)はサーバーサイドでの処理を行うことになります。サーバーからのドキュメントの読み書きに際しては認証が要らないため大変便利ですが(firestoreのルールに拘束されません)、firebase-adminというライブラリを別途インストールしておかねばなりません。

//adminFirebase.tsx
import * as admin from "firebase-admin";
import serviceAccount from  "./path/to/serviceAccountKey.json";

const cert={
    projectId:serviceAccount.project_id,
    privateKey:serviceAccount.private_key,
    clientEmail:serviceAccount.client_email,
}

let adminApp
if(!admin.apps.length){        
    adminApp=admin.initializeApp({
        credential:admin.credential.cert(cert)
    })
}

export const database=admin.firestore(adminApp);
export default admin;

③商品一覧ページの作成
トップページは以下のようなイメージで組み立てています。
image.png
実際のコードは以下の通りです。

//index.tsx
export async function getServerSideProps(context:GetServerSidePropsContext){
  context.res.setHeader("Cache-Control", "public, s-maxage=21600, stale-while-revalidate=25200");
  const host = context.req.headers.host || 'localhost:3000';
  const protocol = /^localhost/.test(host) ? 'http' : 'https';
 
  try{
    const res = await fetch(`${protocol}://${host}/api/products`).catch(e=>{throw new Error("fail to fetch the product information.This might caused from a network error")});
    const products:Products[] = await res.json(); 
    const integratedProducts=await Promise.all(products.map(async(product)=>{
        const docRef=database.collection("items").doc(product.id);
        let docSnap=await docRef.get();
        docSnap.exists || await docRef.set({
                                                id:product.id,
                                                name:product.name,
                                                displayStock:0,
                                                realStock:0,
                                                orders:0,
                                                sents:0,
                                              });
/*
    もし商品情報がfirestoreに登録されていないのであればsetメソッドを用いて書き込み、再び同じドキュメントを参照してデータを読み込みます。
    もし商品情報がfirestoreに登録されてあるのであれば、同じ情報が再代入されるだけです。
*/
        docSnap=await docRef.get();
        const data=docSnap.data(); 
        const controlData={
          displayStock:data?.displayStock || 0 ,
          realStock:data?.realStock || 0,
          orders:data?.orders || 0,
          sents:data?.sents || 0
        };
          return{...product,...controlData}
/*
    商品情報と、データベースに保存されてある在庫数などの諸々の情報を統合して返してあります。
    もし、別に表示在庫数以外の情報が要らなければ、return{...product,displayStock:data?.displayStock}となると思います。
*/
    }))
    const collectionRef=database.collection("items");
    const querySnapShot=await collectionRef.get();
    await Promise.all(querySnapShot.docs.map(async(d)=>{
          products.filter(product=>product.id === d.id).length || await collectionRef.doc(d.id).delete();
    }));
/*
    アーカイブされた商品について、firestoreの情報も削除する処理を書いています。
    もしitemsコレクションの中で、ある商品と紐づいたドキュメントのidがproductのidに一致するものがなければ、
    その商品はStripeでアーカイブされていることになるはずなので、firestoreの方でも当該ドキュメントを削除しています。
*/
    return{
      props:{
        products:integratedProducts
      }
    }
  }catch(error){
    return{
      props:{
        error:JSON.stringify(error)
      }
    } 
  }
}

export default function Home({products,error}:IntegratedProducts) {
  const {addItem}=useShoppingCart();
  const {user}=useAuthenticate();
//エラーハンドリングは省略。

  return (
    <main className = "p-10 ">
      <div className="md:float-left md:max-w-[75%]  flex flex-wrap justify-evenly ">
      {products.map(product=>{
        return(
          <div key={product.id} className="flex w-1/2 min-w-[400px] max-md:w-full p-1">
            <div className="relative w-[40%]  ">
              <Image src={product.images[0]} alt={product.name}  fill style={{objectFit:"cover",backgroundPosition:"center",borderRadius:"12px",boxShadow:"2px 2px 1px brown"}}/>
            </div>
            <div className="p-1 mx-1 w-[60%] border-2 border-black rounded">
              <h3 className="text-lg font-bold">{product.name}</h3>
              <p className="text-base">{product.description}</p>
              <div>
                {product.prices.map(price=>{
                  return (
                    <dl key={price.id} className="table">
                      <dt>価格</dt>
                      <dd>
                        <span>{price.unit_amount.toLocaleString()}:{price.currency.toUpperCase()}</span>
                        {price.transform_quantity && (<small>{price.transform_quantity.divide_by}アイテム毎</small>)}
                      </dd>
                      <dt>在庫</dt>
                      <dd>{Number(product.displayStock) >= 5 ? product.displayStock : "切らしかけております"}</dd>
                        {!product.displayStock || Number(product.displayStock) < 5 ? (
                        <div>
                          申し訳ございませんが品薄でございます在庫が補充されるまでお待ちください
                        </div>):(
                        <div>
                          <form action="/api/checkout_session" method="POST">
                            <input type="hidden" name="price" value={price.id} />
                            <input type="hidden" name="quantity" value={1} />
                            <button className="border rounded border-black bg-blue-400 py-1 px-2 m-1 hover:opacity-85 hover:bg-blue-300" type="submit">
                              今すぐ注文する
                            </button>
                          </form>
                          <button 
                            className="border rounded border-black bg-blue-400 py-1 px-2 m-1 hover:opacity-85 hover:bg-blue-300"
                            onClick={()=>addItem({
                              id: price.id,
                              name: product.name,
                              price: price.unit_amount,
                              currency: price.currency,
                              image: product.images[0],
                            })}>
                              カートに追加する
                            </button>
                          </div>) }
                    </dl>
                  )
                })}
              </div>
            </div>
          </div>
        )
      })}
    </div>
    <div className="md:float-right">
      <CartDetail/>
    </div>
    </main>
  )
}

商品情報はStripeのダッシュボードにて更新されるかもしれません。値段が変わったりなどです。そのため、定期的に情報を更新しておきたいのですが、firebase HostingにおいてISRはほとんど実装することができないようです。かといってSSRを実装すると、かなり頻繁にデータフェッチが行われることになり、UXが下がるとともにfirestoreの読み取りの回数が無駄に膨らむことになりかねません。かわりに、ISRっぽい挙動をするように、SSRにおいてcontext.res.setHeader("Cache-Control", "public, s-maxage=21600, stale-while-revalidate=25200");として、キャッシュを指定する処理を組み込むことができます。(参考2)

また、サーバーサイドでfirestoreを読み取ろうとする際はfirebase-adminを用いて、v8での書き方をしなければなりません。

index.tsxでは商品情報の取得及び表示が主な役割ですが、SSRの中で商品情報の登録、削除まで行っています。もしかしたら、apiとuseSWRを用いて共通化するかもしれませんが、今のところはSSRのなかで全て済ませています。

④配送情報(注文者情報)の登録

//webhook.tsx
import { database } from "@/lib/adminFirebase";
import { buffer } from "micro";
import { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";

const endpointSecret=process.env.STRIPE_WEBHOOK_SECRET ;

interface CheckoutSession extends Stripe.Checkout.Session {}

const stripe= new Stripe(process.env.STRIPE_API_KEY!,{
    apiVersion:"2022-11-15"
});

export const config={
    api:{
        bodyParser:false
    }
};

export default async function handler(
    req:NextApiRequest,
    res:NextApiResponse
){
    const sig=req.headers["stripe-signature"];
    const buf=await buffer(req);
    let event;
    try{
        if(!sig) {throw new Error("No signature provided")}
        if(!endpointSecret){ throw new Error("fail to fetch webhook")}
        event=stripe.webhooks.constructEvent(buf,sig,endpointSecret);
    }catch(error){
        const err=error instanceof Error ? error: new Error("Bad Request");
        res.status(400).send(`Webhook Error:${err.message}`);
        return ;
    }

    if(!event){
        res.status(400).send(`Webhook Error: Event not defined`)
        return ;
    }

    const data:CheckoutSession=event.data.object as Stripe.Checkout.Session;
    
    if(data.payment_status==="paid"){
        const items = await stripe.checkout.sessions.listLineItems(data.id);
        const orderedProducts=await Promise.all(items.data.map(async(item)=>{
            const name=item.description;
            const queryRef=database.collection("items").where("name","==",name).limit(1);
            const snapShot=await queryRef.get();
            const previousDoc=snapShot.docs[0];
/*
    商品idがcheckoutsessionのListLineItemsの中にはなく、決済情報と商品情報を直接結び付ける情報が商品名しかなかったため、
    クエリ検索で商品名が一致するものを返しています。
*/ 
            if(!previousDoc) throw new Error("fail to fetch Data while getting firestore docs");
            if(!item) throw new Error("the item doesn't exist");
            const targetRef=database.collection("items").doc(previousDoc.id);
            
            await targetRef.update({
                orders:previousDoc.data().orders + Number(item.quantity) ,
                displayStock:previousDoc.data().displayStock - Number(item.quantity),
            });
        }));
        
        
        const customer_detail=data.customer_details;
        const prefecture=customer_detail?.address?.state || "";
        const address=(customer_detail?.address?.line1 || "") + (customer_detail?.address?.line2 || "");
        const postal_code=customer_detail?.address?.postal_code || "";
        const email=customer_detail?.email || "";
        const name=customer_detail?.name || "";
        const phoneNumber=customer_detail?.phone || "";
        const id=data.id
        const now=new Date().getTime();
        const date=(new Date()).toString();
        
        const colRef=database.collection("orderedCustomer");
        
        await Promise.all(items.data.map(async(item)=>(
            await colRef.add({
            prefecture,address,postal_code,email,phoneNumber,id,name,date,itemName:item.description,quantity:item.quantity
            })))
        )
    }
    return res.status(200).end() ;
}

⑤管理者画面の追加
管理者の名前(id)とパスワードを入力するフォームを置き、正しい値を入力できた人だけが、即ち管理者だけがデータベースに保存した情報を管理できるようにします。
image.png

具体的には、firestoreに管理者IDとパスワードを予め登録しておき、SSRでその二つの情報を取得しておきます。もしインプットフォームから入力したIDとパスワードが正しければカスタムトークンを発行します。firestoreのルールにおいてはカスタムトークンによる認証ができていなければすべてのデータの読み書きができないようにしているので、ある程度のセキュリティは担保されるはずです。

//manager.tsx
import  {lazy,Suspense, FormEvent,  useRef, useState } from "react";
import admin,{ database } from "@/lib/adminFirebase";
import {getAuth,signInWithCustomToken} from "firebase/auth"
import { ManagerAccount } from "@/type/ManagerAccount";
import {v4 as uuidv4} from "uuid";
import { OrderedCustomer } from "@/type/OrderedCustomer";
import { Products } from "@/type/Products";
import { GetServerSidePropsContext } from "next";

const Administer=lazy(()=>import("@/components/administer"));

type ServerSideProps={
    url:string;
    name?:string;
    password?:string;
    token?:string;
    error?:Error ;
};

export async function getServerSideProps(context:GetServerSidePropsContext){
    context.res.setHeader("Cache-Control", "public, s-maxage=3000, stale-while-revalidate=3600");
/*
    カスタムトークンは一時間毎に失効、破棄されてしまうので、
    キャッシュ制御によって最長で一時間とにサーバーへリクエストを送るようにしています。  
*/ 
    const host = context.req.headers.host || 'localhost:3000';
    const protocol = /^localhost/.test(host) ? 'http' : 'https';
    const url=`${protocol}://${host}/api/products`;
    try{ 
        const managerAccountRef=database.collection("manager").doc("managerAccount");
        const managerAccountDoc=await managerAccountRef.get();
        if(managerAccountDoc.exists){
            const {name,password}= managerAccountDoc.data() as ManagerAccount;
            const uid=uuidv4();
            const customToken=await admin.auth().createCustomToken(uid);

            return{
                props:{url,name,password,token:customToken}
            }
            }else{
            throw new Error("something has gone wrong with SRG!");
        }
    }catch(error){
        return{
            props:{
                error:JSON.stringify(error)
            }
        }
    }
}

const Manager=({url,name,password,token,error}:ServerSideProps)=>{
    const [authentication,setAuthentication]=useState<boolean>(false);
    const [products,setProducts]=useState<Products[]|undefined>([]);
    const [customers,setCustomers]=useState<OrderedCustomer[]>([]);  
    const nameRef=useRef<HTMLInputElement>(null);
    const passRef=useRef<HTMLInputElement>(null);

    if(error instanceof Error){
        window.alert(error.message);
    }else if(error){
        window.alert(error);
    }
    
    if(!token){ 
        window.alert("There is no token! Something has gone wrong through communicating with database")
        return(
            <div className="w-full  h-[100vh] flex flex-col items-center justify-center">
                there is no token! This problem caused from limitation of database quota. Please retry after a while...
            </div>
        )
    }

    const onSubmit=async(e:FormEvent<HTMLFormElement>)=>{
        e.preventDefault();
        const inputName=nameRef.current ? nameRef.current.value : ""  ;
        const inputPassword=passRef.current ? passRef.current.value : "";
        if(inputName===name && inputPassword=== password){
            const auth=getAuth();
            const userCredential=await signInWithCustomToken(auth,token).catch(e=>{throw e});
            userCredential && setAuthentication(true);
        }
        if(!nameRef.current) return ;
        if(!passRef.current) return ;
        nameRef.current.value="";
        passRef.current.value="";
    };

    return(
            <main className="flex min-h-[100vh]  flex-col items-center justify-around p-10 text-center ">
                {authentication || (<div>
                    <form onSubmit={(e)=>onSubmit(e)}>
                        <div>
                            <label htmlFor="name" className="m-1 hover:cursor-pointer">Name:</label>
                            <input id="name" name="name" type="text" ref={nameRef} placeholder="Please write manager name to this form" className="p-1"/>
                        </div>
                        <div>
                            <label htmlFor="password" className="m-1 hover:cursor-pointer">Password:</label>
                            <input type="password" name="password" ref={passRef} placeholder="Please write password to this form" className="p-1"/>
                        </div>                        
                        <button type="submit" className="border rounded border-black bg-blue-400 py-1 px-2 m-1 hover:opacity-85 hover:bg-blue-300">Submit</button>
                    </form>
                </div>)}
                
                { authentication ?
                     (
                        <Suspense fallback={<>Loading...</>}>
                            <Administer flag={authentication} url={url}/>
                        </Suspense>
/*
    非同期的な処理が入ってくるので、ここでsuspenseで囲って、ロード中はloadの文字が出るようにしています。
    importをlazyで動的な読み込みに変えることができます。
*/
                    ):(
                    <div>
                            Who are you?
                    </div>
                    )
                }
            
            </main>
    )
}
export default Manager;

《管理者の名前(id)とパスワードが正しく入力されていたら》
Administerコンポーネントの中に認証が完了したかどうかを管理するauthnethicationをflagとして渡しています。flagがtrueだった場合にフロントエンドからfirestoreを読み書きする処理を行うようにしています。
親コンポーネントでカスタムトークンによる認証ができているので、子コンポーネントにおける操作がfirestoreに反映されるはずです。

商品管理は各商品ごとにこのようなブロックを作って並べます。
image.png

stripeの購入画面に入力された情報をfirestoreに登録していたはずなので、顧客情報を入手してまとめたブロックを並べるようにします。
image.png
実際のコードは以下の通りです。

//administer.tsx
import { db } from "@/lib/firebase";
import { OrderedCustomer } from "@/type/OrderedCustomer";
import { Products } from "@/type/Products";
import { collection, deleteDoc, doc, getDoc, getDocs, updateDoc } from "firebase/firestore";
import Image from "next/image";
import { FormEvent, useCallback, useEffect, useState } from "react";

type Props={
    url:string;
    flag:boolean;
};

type AugmentedCustomer=OrderedCustomer & {docId:string;}

const Administer= ({url,flag}:Props):JSX.Element=>{
    const [customers,setCustomers]=useState<AugmentedCustomer[]|undefined>();
    const [products,setProducts]=useState<Products[]|undefined>();

    useEffect(()=>{
        (async()=>{
            if(!flag) return ; 
            const res = await fetch(url);
            const products:Products[] =await res.json();
            const integratedProducts=await Promise.all(products.map(async(product)=>{
                const docRef=doc(db,"items",product.id);
                const docSnap=await getDoc(docRef);
                const data=docSnap.data();
                const controlData= data && {displayStock:data.displayStock,realStock:data.realStock,orders:data.orders,sents:data.sents};
                return{...product,...controlData};
            }));
            setProducts(integratedProducts);
        })();
    },[flag,url]);
    
   useEffect(()=>{
    (async()=>{
        if(!flag) return ;
        const colRef=collection(db,"orderedCustomer");
        const querySnapShot=await getDocs(colRef);
        const customerList=querySnapShot.docs.map(doc=>{
            const data=doc.data() as OrderedCustomer;
            const docId=doc.id as string;
            return {docId,...data}
        });
        setCustomers(customerList);
//注文商品ごとに固有のidを与えているため、ドキュメントの自動生成されているidを指定して、商品ごとに管理するようにしています。
    })();
    },[flag]);
    
    const onSubmit=useCallback(async(e:FormEvent<HTMLFormElement>,id:string)=>{
        e.preventDefault();
        if(!flag) return ;
        
        let upDatedDisplayStock=e.currentTarget[`${id}displayStock`].value; 
        let upDatedRealStock=e.currentTarget[`${id}realStock`].value; 
        let upDatedOrders=e.currentTarget[`${id}orders`].value; 
        let upDatedSents=e.currentTarget[`${id}sents`].value;

        if (upDatedDisplayStock === "" || upDatedDisplayStock === null || typeof upDatedDisplayStock === "boolean") {
            upDatedDisplayStock = NaN;
        }
        if (upDatedRealStock === "" || upDatedRealStock === null || typeof upDatedRealStock === "boolean") {
        upDatedRealStock = NaN;
        }
        if (upDatedOrders === "" || upDatedOrders === null || typeof upDatedOrders === "boolean") {
        upDatedOrders = NaN;
        }
        if (upDatedSents === "" || upDatedSents === null || typeof upDatedSents === "boolean") {
        upDatedSents = NaN;
        }          
/*
    inputにnumber型以外が何らかの不備で入っている場合に備えて、それらをNaNにすげ替え、入力値がNaN(主に何も入力されていないことを想定)のときはfalseとします。
    いずれかひとつがtrueのときはなにか数字が入力されているはずなのでconfirmメソッドを呼び出し、不用意な更新を防ぐようにひと手間加えます。
*/ 
        const isConfirmed=(!isNaN(Number(upDatedDisplayStock)) || !isNaN(Number(upDatedRealStock)) ||  !isNaN(Number(upDatedOrders)) ||  !isNaN(Number(upDatedSents))) && confirm("在庫や注文、配送数は正しいですか?変更を加えてよろしいですか?");
        if(!isConfirmed) return ;
        e.currentTarget[`${id}displayStock`].value=null; 
        e.currentTarget[`${id}realStock`].value=null; 
        e.currentTarget[`${id}orders`].value=null; 
        e.currentTarget[`${id}sents`].value=null;  

        const docRef=doc(db,"items",id);
        const docSnap=await getDoc(docRef);
        const data=docSnap.data();
        if(!data) window.alert("There is nothing in database");
//文字の入力がない部分については、以前の値を代入するようにしています。

        const validatedDisplayStock= !isNaN(Number(upDatedDisplayStock)) ? upDatedDisplayStock :  data?.displayStock;
        const validatedRealStock= !isNaN(Number(upDatedRealStock)) ? upDatedRealStock : data?.realStock;
        const validatedOrders=  !isNaN(Number(upDatedOrders)) ? upDatedOrders : data?.orders;
        const validatedSents= !isNaN(Number(upDatedSents)) ? upDatedSents : data?.sents;
        
        await updateDoc(docRef,{
        displayStock:Number(validatedDisplayStock),
        realStock:Number(validatedRealStock),
        orders:Number(validatedOrders),
        sents:Number(validatedSents)
        })

        if(!products) return ;
        const index =products.findIndex(element => element.id === id);
        if (index === -1) throw new Error("Nothing was found");
/*
    言ってみればfirestoreのデータベースとここにおけるproductは同期していません。
    つまり、わざわざデータベースからもう一度情報を取ってくるような処理はしていないということです。
    このプロジェクトでは管理者画面において異様に読み取りのコストが高くなってしまっているので、
    updateDocに使った情報をstateに保存させ、表示画面の情報とデータベースでの値が一致するようにしています。
*/ 
        const updatedElement = {
        ...products[index],
        displayStock:validatedDisplayStock,
        realStock:validatedRealStock,
        orders:validatedOrders,
        sents:validatedSents,
        };
        const updatedProducts = [...products];
        updatedProducts[index] = updatedElement;
        setProducts(updatedProducts);
    },[flag,products]);

    const onSendProducts=useCallback(async(id:string)=>{
        const isConfirmed=confirm("本当に荷物を配送できましたか?");
        if(!flag) return ;
        if(!isConfirmed) return ;
        const colRef=collection(db,"orderedCustomer");
        const docRef=doc(colRef,id);
        await deleteDoc(docRef);
        const querySnapShot=await getDocs(colRef);
        const customerList=querySnapShot.docs.map(doc=>{
            console.log(doc.id===id)
            const data=doc.data() as OrderedCustomer
            return {docId:id,...data}
        });
        setCustomers(customerList);
    },[flag]);   

    return(
        <div className="w-full p-10 text-center flex flex-col items-center justify-center">
            {products && products.map(product=>{
                return(
                <div key={product.id} className="flex min-w-[400px] w-[80%] m-2">
                    <div className="relative w-1/2  m-1 p-1 ">
                        <Image src={product.images[0]} alt={product.name}  fill style={{objectFit:"cover",backgroundPosition:"center",borderRadius:"12px",boxShadow:"2px 2px 1px brown"}}/>
                    </div>
                    <div className="text-center p-1 mx-1 w-1/2 border-2 border-black  rounded">
                    <h3 className="text-lg font-bold">{product.name}</h3>
                    <div>
                        {product.prices.map(price=>{
                        return (
                            <dl key={price.id} className="table">
                                <form onSubmit={(e)=>{onSubmit(e,product.id)}}>
                                    <div>
                                        <label htmlFor="displayStock" className="hover:cursor-pointer">表示在庫{product.displayStock}</label>
                                        <input type="number" min="0" id="displayStock" name={`${product.id}displayStock`} className="p-1 m-1 border rounded border-red-400"/>
                                    </div>
                                    <div>
                                        <label htmlFor="realStock" className="hover:cursor-pointer">実在庫{product.realStock}</label>
                                        <input type="number" min="0" id="realStock" name={`${product.id}realStock`} className="p-1 m-1 border rounded border-red-400"/>
                                    </div>
                                    <div>
                                        <label htmlFor="orders" className="hover:cursor-pointer">注文数合計):{product.orders}</label>
                                        <input type="number" min="0" id="orders" name={`${product.id}orders`} className="p-1 m-1 border rounded border-red-400" />
                                    </div>
                                    <div>
                                        <label htmlFor="sents" className="hover:cursor-pointer">発送数合計):{product.sents}</label>
                                        <input type="number" min="0" id="sents" name={`${product.id}sents`} className="p-1 m-1 border rounded border-red-400 " />
                                    </div>
                                    <button type="submit"  className="border rounded border-black bg-blue-400 py-1 px-2 m-1 hover:opacity-85 hover:bg-blue-300">更新</button>
                                </form>               
                            </dl>
                        )
                        })}
                    </div>
                    </div>
                </div>
                )
            })}
            <div className="flex flex-wrap p-1 m-1">
                {customers?.length ? (
                    customers.map((customer)=>(
                        <div key={customer.docId} className="text-left bg-green-200 border rounded border-black opacity-90 p-2 m-1 min-w-[49%]">
                            <ul>
                                <li>住所{customer.prefecture} {customer.address}</li>
                                <li>郵便番号{customer.postal_code}</li>
                                <li>メールアドレス{customer.email}</li>
                                <li>名前{customer.name}</li>
                                <li>電話番号{customer.phoneNumber}</li>
                                <li>購入日{customer.date}</li>
                                <li>購入品{customer.itemName}</li>
                                <li>購入数{customer.quantity}</li>
                            </ul>
                            <button onClick={()=>onSendProducts(customer.docId)}  className="border rounded border-black bg-blue-400 py-1 px-2 m-1 hover:opacity-85 hover:bg-blue-300">Complete to send!</button>
                        </div>
                        ))
                    ):(
                        <div className="w-full p-3 border border-black rounded-lg">
                            <h2 className="font-bold">注文明細</h2>
                            <p>注文したお客様はいらっしゃいません</p>
                        </div>
                    )}
            </div>
        </div>
    )

};
export default Administer

これで、概ね要件を満たしたECサイトが完成しました。
まだ本番環境にデプロイしていないため、そこら辺のこまごまとした情報は把握しきれていませんが、もっと体裁を整え、実際のWebアプリとして公開できるように洗練させていきたいです。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?