iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Software Development

我獨自開發 - 用 Supabase 打造全端應用系列 第 29

第二十九關 - 來企排隊:Google 地圖整合與簡訊邀請

  • 分享至 

  • xImage
  •  

封面

功能

在「來企排隊」系統中,我們整合了 Google 地圖功能,讓用戶可以:

  • 查看附近的商家位置
  • 獲取商家詳細資訊(名稱、地址、電話)
  • 直接發送簡訊邀請商家加入平台
  • 未來支援商家自主定位和直接預約

為什麼需要這個功能?

解決的問題

  1. 發現新商家:用戶可以找到附近還沒加入平台的優質商家
  2. 擴展平台:透過用戶邀請,快速增加平台商家數量
  3. 快速搜尋:整合地圖讓找店變得更容易

使用場景

  • 用戶在新地區想找餐廳,但平台上選擇有限
  • 發現好店想邀請加入平台
  • 商家想要在地圖上標示位置供用戶預約

功能實現流程

1. 開啟地圖功能

商店列表頁面 → 點擊地圖按鈕 → 開啟 Google 地圖彈窗

2. 定位與搜尋

  • 自動獲取用戶當前位置
  • 搜尋附近 1 公里範圍內的商家
  • 顯示餐廳、商店等營業場所

3. 查看商家資訊

  • 點擊地圖上的商家標記
  • 顯示商家基本資訊:
    • 商家名稱
    • 完整地址
    • 聯絡電話(如有提供)

4. 發送邀請簡訊

  • 點擊「發送訊息」按鈕
  • 自動產生邀請簡訊內容
  • 開啟手機簡訊應用程式
  • 用戶確認發送邀請

簡訊邀請系統

邀請訊息

親愛的 [商家名稱]:
[用戶姓名] 傳送預約邀請,請登入 來企排隊 官方網站,查看詳細資訊。

商家接收流程

  1. 收到簡訊:商家收到用戶發送的邀請簡訊
  2. 登入網站:商家註冊並登入「來企排隊」
  3. 查看邀請:系統自動顯示待處理的邀請通知
  4. 建立商店:如果商家名稱不符,需要建立同名商店
  5. 接受邀請:確認邀請並建立正式預約訂單

自動匹配

  • 系統會比對商家手機號碼
  • 自動檢查是否有對應的邀請記錄
  • 確保邀請與商家帳號正確配對

技術

1. 即時定位

  • 使用瀏覽器 GPS 定位
  • 顯示用戶位置

2. 搜尋篩選

  • 整合 Google Places API
  • 篩選營業場所類型

未來規劃

1. 商家定位功能

  • 商家可以在地圖上標示精確位置
  • 設定營業時間和服務項目

2. 直接預約功能

  • 用戶可以直接在地圖上預約
  • 即時查看排隊狀況
  • 預估等待時間顯示

3. 進階搜尋

  • 評分和評論顯示
  • 距離和營業狀態排序

4. 社群功能

  • 用戶可以推薦商家
  • 分享地圖位置

技術實作

第一步:Google Maps API 設定

1.1 申請 Google Cloud 專案

  1. 前往 Google Cloud Console
  2. 建立新專案或選擇現有專案
  3. 啟用以下 API 服務:
    • Maps JavaScript API:用於顯示地圖
    • Places API (New):用於搜尋附近商家
    • Geocoding API:用於地址轉換

1.2 取得 API 金鑰

# 在 Google Cloud Console 中
1. 前往「APIs & Services」→「Credentials」
2. 點擊「Create Credentials」→「API Key」
3. 複製產生的 API 金鑰
4. 設定 API 金鑰限制(限制網域和 API)

1.3 環境變數設定

NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-google-maps-api-key
NEXT_PUBLIC_GOOGLE_MAPS_ID=your-google-maps-id

第二步:Google Maps 組件實作

2.1 建立地圖彈窗組件

// components/GoogleMapModal.tsx
export default function GoogleMapModal({ isOpen, onClose }) {
  useEffect(() => {
    if (!isOpen) return;

    const initMap = () => {
      if (!window.google || !mapRef.current) return;

      // 取得用戶位置
      if (navigator.geolocation) {
        navigator.geolocation.getCurrentPosition(
          async (position) => {
            const userLocation = {
              lat: position.coords.latitude,
              lng: position.coords.longitude,
            };

            // 建立地圖實例
            const newMapInstance = new window.google.maps.Map(mapRef.current, {
              center: userLocation,
              zoom: 15,
              mapId: process.env.NEXT_PUBLIC_GOOGLE_MAPS_ID,
            });

            setMapInstance(newMapInstance);
            await searchNearbyPlaces(userLocation, newMapInstance);
          },
          (error) => {
            setError("無法取得您的位置,請檢查位置權限設定");
            setIsLoading(false);
          }
        );
      }
    };

    // 載入 Google Maps API
    if (!window.google) {
      const script = document.createElement("script");
      script.src = `https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&libraries=places,marker&loading=async&callback=initGoogleMap`;
      script.async = true;
      script.defer = true;
      
      window.initGoogleMap = initMap;
      document.head.appendChild(script);
    } else {
      initMap();
    }
  }, [isOpen]);

  return (
    <div>
      ...
        <div ref={mapRef} />
      ...
    </div>
  );
}

2.2 搜尋附近商家

const searchNearbyPlaces = async (userLocation, mapInstance) => {
  try {
    // 檢查 Places API 是否可用
    if (!window.google.maps.places?.Place?.searchNearby) {
      setIsLoading(false);
      return;
    }

    const request = {
      includedTypes: ["store", "restaurant"],
      locationRestriction: {
        center: userLocation,
        radius: 1000, // 1公里範圍
      },
      fields: ["id", "displayName", "location", "formattedAddress"],
    };

    const { places } = await window.google.maps.places.Place.searchNearby(request);
    
    if (places && places.length > 0) {
      places.forEach((place) => createMarker(place, mapInstance));
    }
  } catch (err) {
    console.error("Places search failed:", err);
  } finally {
    setIsLoading(false);
  }
};

第三步:簡訊邀請系統

3.1 資料庫設計

-- supabase/migrations/create_sms_invitations_table.sql
CREATE TABLE IF NOT EXISTS public.sms_invitations (
    id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
    created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
    updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
    
    -- 發送者資訊
    sender_user_id UUID REFERENCES public.users(id) ON DELETE CASCADE,
    sender_name TEXT NOT NULL,
    sender_phone TEXT,
    
    -- 商家資訊
    store_phone TEXT NOT NULL, -- 商家手機號碼
    store_name TEXT NOT NULL,  -- 從地圖獲取的商家名稱
    store_place_id TEXT,       -- Google Places ID
    
    -- 邀請狀態
    status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'converted', 'expired')),
    booking_id UUID REFERENCES public.bookings(id) ON DELETE SET NULL,
    expires_at TIMESTAMPTZ DEFAULT (now() + INTERVAL '7 days') NOT NULL,
    message_content TEXT
);

-- 創建唯一索引:只對 pending 狀態保持唯一性
CREATE UNIQUE INDEX sms_invitations_pending_unique 
ON public.sms_invitations (sender_user_id, store_phone) 
WHERE status = 'pending';

3.2 建立邀請函數

-- 創建簡訊邀請的資料庫函數
CREATE OR REPLACE FUNCTION create_sms_invitation(
    p_store_phone TEXT,
    p_store_name TEXT,
    p_store_place_id TEXT DEFAULT NULL,
    p_message_content TEXT DEFAULT NULL
)
RETURNS TABLE (
    invitation_id UUID,
    message TEXT
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
    v_user_id UUID;
    v_user_name TEXT;
    v_invitation_id UUID;
    v_default_message TEXT;
BEGIN
    -- 獲取當前用戶資訊
    SELECT auth.uid() INTO v_user_id;
    
    IF v_user_id IS NULL THEN
        RAISE EXCEPTION 'User must be authenticated';
    END IF;
    
    -- 獲取用戶姓名
    SELECT name INTO v_user_name FROM public.users WHERE id = v_user_id;
    
    -- 生成預設簡訊內容
    v_default_message := format(
        '親愛的 %s:%s 傳送預約邀請,請登入 來企排隊 官方網站,查看詳細資訊。',
        p_store_name,
        v_user_name
    );
    
    -- 創建邀請記錄
    INSERT INTO public.sms_invitations (
        sender_user_id, sender_name, store_phone, store_name, 
        store_place_id, message_content
    ) VALUES (
        v_user_id, v_user_name, p_store_phone, p_store_name,
        p_store_place_id, COALESCE(p_message_content, v_default_message)
    ) RETURNING id INTO v_invitation_id;
    
    RETURN QUERY SELECT v_invitation_id, v_default_message;
END;
$$;

3.3 前端簡訊發送實作

const handleSendSMS = async (phoneNumber: string, businessName: string) => {
  try {
    // 創建邀請記錄
    const { data, error } = await supabase.rpc('create_sms_invitation', {
      p_store_phone: phoneNumber,
      p_store_name: businessName,
      p_store_place_id: null,
      p_message_content: null // 使用預設訊息
    });

    // 獲取用戶資訊
    const { data: { user } } = await supabase.auth.getUser();
    const { data: userProfile } = await supabase
      .from('users')
      .select('name')
      .eq('id', user?.id)
      .single();
    
    const userName = userProfile?.name || '用戶';
    
    // 發送簡訊
    const message = data?.message || `親愛的 ${businessName}:${userName} 傳送預約邀請,請登入 來企排隊 官方網站,查看詳細資訊。`;
    const smsLink = `sms:${phoneNumber}?body=${encodeURIComponent(message)}`;
    window.location.href = smsLink;
    
    toast.success('預約邀請已發送!商家確認後您將收到通知。');
  } catch (err) {
    console.error('Error sending SMS invitation:', err);
  }
};

小結

透過地圖操作,用戶可以發現新商家並邀請加入,而商家也能透過這個機制了解並使用服務。

... to be continued

有任何想討論歡迎留言,或需要指正的地方請鞭大力一點,歡迎訂閱、按讚加分享,分享給想要提升開發效率的朋友


上一篇
第二十八關 - 來企排隊: Supabase 快速建立手機號碼登入
系列文
我獨自開發 - 用 Supabase 打造全端應用29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言