Repository: team-mirai/action-board
Pull request #45: feat: シェアボタンの実装
Author: login: dotneet
Created: 2025-05-20T15:27:00Z | Merged: 2025-05-20T17:26:09Z

 - 機能のみ実装
 - Xでのシェア、navigator.share()によるシェアボタンの実装
 - navigator.share() はデスクトップだと微妙なので非表示

X以外にちゃんと対応した方がいいサービスがあればご指摘ください。facebookとLINEあたりはあった方がいい？

<img width="307" alt="image" src="https://github.com/user-attachments/assets/88d2290f-3dac-48a7-b722-88b2144581ab" />

<img width="617" alt="image" src="https://github.com/user-attachments/assets/acd2804c-4cf8-4c9d-b09c-a2ad94f54ba1" />

関連Issue
#26 



Diff:
diff --git a/app/missions/[id]/complete/ShareButton.tsx b/app/missions/[id]/complete/ShareButton.tsx
new file mode 100644
index 000000000..47190adab
--- /dev/null
+++ b/app/missions/[id]/complete/ShareButton.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Share } from "lucide-react";
+
+type Props = {
+  children: React.ReactNode;
+  message: string;
+  missionId: string;
+  className?: string;
+  url?: string;
+};
+
+export function ShareButton({
+  children,
+  message,
+  missionId,
+  className,
+  url,
+}: Props) {
+  const shareUrl = url ?? `${window.location.origin}/missions/${missionId}`;
+  const handleShare = async () => {
+    if (navigator.share) {
+      try {
+        await navigator.share({
+          title: "チームみらい Action Board",
+          text: message,
+          url: shareUrl,
+        });
+      } catch (error) {
+        // シェアがキャンセルされた場合やエラー時は何もしない
+      }
+    } else {
+      alert("このブラウザはWebシェアAPIに対応していません。");
+    }
+  };
+  return (
+    <Button variant="outline" onClick={handleShare} className={className}>
+      <Share className="w-4 h-4" />
+      {children}
+    </Button>
+  );
+}
diff --git a/app/missions/[id]/complete/ShareTwitterButton.tsx b/app/missions/[id]/complete/ShareTwitterButton.tsx
new file mode 100644
index 000000000..f6adde2c2
--- /dev/null
+++ b/app/missions/[id]/complete/ShareTwitterButton.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+
+type Props = {
+  children: React.ReactNode;
+  message: string;
+  missionId: string;
+  className?: string;
+  url?: string;
+};
+
+export function ShareTwitterButton({
+  children,
+  message,
+  missionId,
+  className,
+  url,
+}: Props) {
+  // SNSシェア用のハンドラ関数
+  const shareUrl = url ?? `${window.location.origin}/missions/${missionId}`;
+  const handleShare = () => {
+    const twitterIntentUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(message)}&url=${encodeURIComponent(shareUrl)}`;
+    window.open(twitterIntentUrl, "_blank", "noopener,noreferrer");
+  };
+  return (
+    <Button variant="outline" onClick={handleShare} className={className}>
+      {children}
+    </Button>
+  );
+}
diff --git a/app/missions/[id]/complete/page.tsx b/app/missions/[id]/complete/page.tsx
index fdb02a8e9..4577f8ce4 100644
--- a/app/missions/[id]/complete/page.tsx
+++ b/app/missions/[id]/complete/page.tsx
@@ -1,6 +1,6 @@
-import { achieveMissionAction } from "@/app/actions";
-import { SubmitButton } from "@/components/submit-button";
 import { createClient } from "@/utils/supabase/server";
+import { ShareButton } from "./ShareButton";
+import { ShareTwitterButton } from "./ShareTwitterButton";
 type Params = {
   id: string;
 };
@@ -26,11 +26,28 @@ export default async function MissionPage({ params }: Props) {
     throw new Error("Mission not found");
   }
 
+  const message = `ミッション「${mission.title}」が完了しました！`;
+  const shareMessage = `チームみらい Action Board で${message}\n`;
+
   return (
     <div className="flex flex-col">
       <h1>おめでとう！</h1>
-      <p>ミッション「{mission.title}」が完了しました！</p>
-      TODO: シェア機能
+      <p>{message}</p>
+      <ShareTwitterButton
+        className="mt-4"
+        message={shareMessage}
+        missionId={id}
+      >
+        Xでシェア
+      </ShareTwitterButton>
+      {/* navigator.share()を使っているのモバイルのみ表示 */}
+      <ShareButton
+        className="mt-2 md:hidden"
+        message={shareMessage}
+        missionId={id}
+      >
+        その他のサービスにシェア
+      </ShareButton>
     </div>
   );
 }


Reviews:
user: login: ryan5500
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:47:38Z
user: login: ryan5500
body: ありがとうございます！一点迷いつつもコメントしました！
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:48:16Z
user: login: dotneet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T16:55:30Z
user: login: dotneet
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T17:10:05Z
user: login: ryan5500
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T17:26:02Z

Review comments:
diff_hunk: @@ -26,11 +29,20 @@ export default async function MissionPage({ params }: Props) {
     throw new Error("Mission not found");
   }
 
+  const message = `ミッション「${mission.title}」が完了しました！`;
+  const shareMessage = `チームみらい Action Board で${message}\n`;
+
   return (
     <div className="flex flex-col">
       <h1>おめでとう！</h1>
-      <p>ミッション「{mission.title}」が完了しました！</p>
-      TODO: シェア機能
+      <p>{message}</p>
+      <ShareTwitterButton className="mt-4" message={shareMessage}>
path: app/missions/[id]/complete/page.tsx
user: login: ryan5500
body: シェアするURLとして、今だとトップページのURL(http://localhost:3000) が共有されると思うのですが、ミッション詳細画面がいいかもしれません。

このポストをみた方は、何かにトライしてるな、何だろうと感じてこのサービスに来ると思いまして、それに答える、共有する場所としてはミッション詳細が適切かなと感じました。
ただ、私も答えを持っているわけではなく、もしトップページに集中させたほうがいいなどありましたらぜひご意見ください。
created_at: 2025-05-20T16:47:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,11 +29,20 @@ export default async function MissionPage({ params }: Props) {
     throw new Error("Mission not found");
   }
 
+  const message = `ミッション「${mission.title}」が完了しました！`;
+  const shareMessage = `チームみらい Action Board で${message}\n`;
+
   return (
     <div className="flex flex-col">
       <h1>おめでとう！</h1>
-      <p>ミッション「{mission.title}」が完了しました！</p>
-      TODO: シェア機能
+      <p>{message}</p>
+      <ShareTwitterButton className="mt-4" message={shareMessage}>
path: app/missions/[id]/complete/page.tsx
user: login: dotneet
body: 確かにミッション詳細が適切だと僕も思いました。修正します。
created_at: 2025-05-20T16:55:30Z
author_association: COLLABORATOR
diff_hunk: @@ -26,11 +29,20 @@ export default async function MissionPage({ params }: Props) {
     throw new Error("Mission not found");
   }
 
+  const message = `ミッション「${mission.title}」が完了しました！`;
+  const shareMessage = `チームみらい Action Board で${message}\n`;
+
   return (
     <div className="flex flex-col">
       <h1>おめでとう！</h1>
-      <p>ミッション「{mission.title}」が完了しました！</p>
-      TODO: シェア機能
+      <p>{message}</p>
+      <ShareTwitterButton className="mt-4" message={shareMessage}>
path: app/missions/[id]/complete/page.tsx
user: login: dotneet
body: こちら修正しました。
created_at: 2025-05-20T17:10:05Z
author_association: COLLABORATOR

PR comments:
user: login: ryan5500
created_at: 2025-05-20T16:52:04Z
body: おっしゃる通り、Facebook, Lineあたりはあるといい気はしました。
MVPとしてはXが優先でできているといいかなと思います。
author_association: CONTRIBUTOR
user: login: dotneet
created_at: 2025-05-20T16:59:31Z
body: > おっしゃる通り、Facebook, Lineあたりはあるといい気はしました。

必須ではないところはどんどんIssueに切り出して、他の開発に参加されたい方向けの good first issue にしていってもよいかと思いました。もちろん後半時間が空いて残ってたらどんどん対応していきますが。
このあたりについてはそういう方針でも良いでしょうか。
author_association: COLLABORATOR
user: login: ryan5500
created_at: 2025-05-20T17:01:26Z
body: 必須でないところはどんどんIssueに切り出す件、素晴らしいと思います！お願いできますか？
author_association: CONTRIBUTOR
user: login: dotneet
created_at: 2025-05-20T17:12:52Z
body: #50 に切り出しておきました〜
author_association: COLLABORATOR