iT邦幫忙

2024 iThome 鐵人賽

2
Software Development

透過 nestjs 框架,讓 nodejs 系統維護度增加系列 第 31

nestjs 系統設計 - 活動訂票管理系統 - client code part 5

  • 分享至 

  • xImage
  •  

nestjs 系統設計 - 活動訂票管理系統 - client code part 5

目標

今天主要會來處理 Ticket 的部份還有根據權限調整顯示畫面的部份。

概念

今天會先處理權限顯示的邏輯。然後一樣先從存取 api 的邏輯開始實做,最後在把畫面實做出來。

實做顯示權限控管

Tab 頁面設定

在 Tab 頁面可以透過 showFor 設定該 tab 只顯示給哪些 role 的使用者來看。設定如下

import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { useAuth } from '@/context/AuthContext';
import { UserRole } from '@/types/user';
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { ComponentProps } from 'react';
import { Text } from 'react-native';

export default function TabLayout() {
  const {user} = useAuth();
  const tabs = [
    {
      showFor: [UserRole.Admin, UserRole.Attendee],
      name: '(events)',
      displayName: 'Events',
      icon: 'calendar',
      options: {
        headerShown: false
      }
    },
    {
      showFor: [UserRole.Attendee],
      name: '(tickets)',
      displayName: 'My Tickets',
      icon: 'ticket',
      options: {
        headerShown: false
      }
    },
    {
      showFor: [UserRole.Admin],
      name: 'scan-ticket',
      displayName: 'Scan Ticket',
      icon: 'scan',
      options: {
        headerShown: true
      }
    },
    {
      showFor: [UserRole.Admin, UserRole.Attendee],
      name: 'settings',
      displayName: 'Settings',
      icon: 'cog',
      options: {
        headerShown: true
      }
    },
  ];
  return <Tabs>
    { tabs.map(tab => (
      <Tabs.Screen 
        key={tab.name}
        name={tab.name}
        options={{
          ...tab.options,
          headerTitle: tab.displayName,
          href: tab.showFor.includes(user?.role!)? tab.name: null,
          tabBarLabel: ({focused}) => (
            <Text style={{color: focused ? 'black': 'gray', fontSize: 12 }}>
              {tab.displayName}
            </Text>
          ),
          tabBarIcon: ({focused}) => (
            <TabBarIcon
              name={tab.icon as ComponentProps<typeof Ionicons>['name']} 
              color={focused? 'black': 'gray'}
            />
          )
        }}
      />
    )) }
  </Tabs>;
}

設定顯示 Attendee 才可以看到 My Tickets 頁面。 Admin 才可以看到 Scan Ticket 的頁面。其他就兩個都可以看到。

驗證

  1. 以 Admin 身分登入
    image

  2. 登入畫面顯示
    image

  3. 以 Attendee 身份登入
    image

  4. 登入畫面顯示
    image

實做 Ticket 相關處理

定義型別

import { ApiResponse } from './api';
import { Event, PageInfo } from './event';
export type TicketResponse = ApiResponse<Ticket>;
export type TicketListResponse = ApiResponse<{tickets: Ticket[], pageInfo: PageInfo}>
export type Ticket = {
  id: string;
  eventId: string;
  event: Event
  entered: boolean
  createdAt: string
  updatedAt: string
}

定義處理 api 的部份

import { TicketResponse, Ticket, TicketListResponse } from '@/types/ticket';
import { Api } from './api';
import { ApiResponse } from '@/types/api';

async function createOne(eventId: string, userId: string): Promise<TicketResponse> {
  return Api.post('/tickets', { eventId, userId, ticketNumber: 1});
}
async function getOne(ticketId: string): Promise<ApiResponse<{ticket: Ticket, qrcode: string}>> {
  return Api.get(`/tickets/${ticketId}`);
}
async function getAll(userId: string): Promise<TicketListResponse> {
  return Api.get(`/tickets?userId=${userId}`);
}
async function validateOne(ticketId: string, userId: string): Promise<TicketResponse> {
  return Api.patch('/tickets', {id: ticketId, userId: userId });
}
const ticketService = {
  createOne,
  getOne,
  getAll,
  validateOne
}

export { ticketService }

Ticket 相關畫面與操作

1 Ticket 表列

import { HStack } from '@/components/HStack';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { useAuth } from '@/context/AuthContext';
import { ticketService } from '@/services/ticket';
import { Ticket } from '@/types/ticket';
import { useFocusEffect } from '@react-navigation/native';
import { router, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { TouchableOpacity, FlatList } from 'react-native';
import Toast from 'react-native-root-toast';

export default function TicketScreen() {
  const navigation = useNavigation();
  const {user} = useAuth();
  const [isLoading, setIsLoading] = useState(false);
  const [tickets, setTickets] = useState<Ticket[]>([]);
  function onGoToTicketPage(id: string) {
    router.push(`/(tickets)/ticket/${id}`);
  }
  async function fetchTickets() {
    try {
      setIsLoading(true);
      const response = await ticketService.getAll(user?.id??'');
      setTickets(response.data.tickets);
    } catch (error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
    } finally {
      setIsLoading(false);
    }
  } 

  useFocusEffect(useCallback(()=>{fetchTickets()}, []));
  useEffect(()=> {
    navigation.setOptions({
      headerTitle: 'Tickets',
    });
  }, [navigation])
  return (
    <VStack flex={1} p={20} pb={0} gap={20}>
      <HStack alignItems='center' justifyContent='space-between'>
        <Text fontSize={18}>{tickets.length} Tickets</Text>
      </HStack>
      <FlatList
        keyExtractor={({id}) => id}
        data={tickets}
        onRefresh={fetchTickets}
        refreshing={isLoading}
        renderItem={({item: ticket}) => (
          <TouchableOpacity 
            disabled={ticket.entered}
            onPress={() => onGoToTicketPage(ticket.id)}
          >
            <VStack
              gap={20}
              h={120}
              key={ticket.id}
              style={{ opacity: ticket.entered? 0.5: 1}}
            >
              <HStack>
                <VStack
                  h={120}
                  w={'69%'}
                  p={20}
                  justifyContent='space-between'
                  style={{
                    backgroundColor: 'white',
                    borderTopLeftRadius: 20,
                    borderBottomLeftRadius: 20,
                    borderTopRightRadius: 5,
                    borderBottomRightRadius: 5
                  }}
                >
                  <HStack alignItems='center'>
                    <Text fontSize={22} bold>{ticket.event.name}</Text>
                    <Text fontSize={22} bold>|</Text>
                    <Text fontSize={22} bold>{ticket.event.location}</Text>
                  </HStack>
                  <Text fontSize={12}>{new Date(ticket.event.startDate).toLocaleString()}</Text>
                </VStack>
                <VStack
                  h={110}
                  w={'1%'}
                  style={{
                    alignSelf: 'center',
                    borderColor: 'lightgray',
                    borderWidth: 2,
                    borderStyle: 'dashed'
                  }}
                />
                <VStack
                  h={120}
                  w={'29%'}
                  justifyContent='center'
                  alignItems='center'
                  style={{
                    backgroundColor: 'white',
                    borderTopRightRadius: 20,
                    borderBottomRightRadius: 20,
                    borderTopLeftRadius: 5,
                    borderBottomLeftRadius: 5,
                  }}
                >
                  <Text fontSize={16} bold>{ticket.entered ? 'Used': 'Available'}</Text>
                  {ticket.entered &&
                    <Text mt={12} fontSize={10}>{new Date(ticket.updatedAt).toLocaleString()}</Text>
                  }
                </VStack>  
              </HStack>
            </VStack>
          </TouchableOpacity>
        )}
        ItemSeparatorComponent={() => <VStack h={20}/>}
      />
    </VStack>
  );
}

預設會透過 fetchTickets 去讀取當下使用者所購買的 Ticket 清單。並且設定載入讀取單獨一個 Ticket 的邏輯為 onGoToTicketPage 。特別注意的是,這邊一樣透過 useFocusEffect 來處理,當畫面載入時的讀取 fetchTicket 事件,還有使用 useEffect 來綁定 navigation 變動。

2 更新 buyTicket 功能

async function buyTicket(id: string) {
    try {
      const response = await ticketService.createOne(id, user?.id??'');
      const toast: Toast = Toast.show(response.message, {
        duration: Toast.durations.LONG,
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
      fetchEvents();
    } catch(error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
    }
  }

這個功能是對應 Event 畫面的 Buy Ticket 按鍵。會先建立一張 Ticket 然後更新當下累積的 Event 購買張數。

3 點入單個 Ticket 的畫面

import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { ticketService } from '@/services/ticket';
import { Ticket } from '@/types/ticket';
import { useFocusEffect } from '@react-navigation/native';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Image } from 'react-native';
import Toast from 'react-native-root-toast';

export default function TicketDetailScreen() {
  const navigation = useNavigation();
  const { id } = useLocalSearchParams();
  const [ticket, setTicket] = useState<Ticket|null>(null);
  const [qrcode, setQrCode] = useState<string|null>(null);
  async function fetchTicket() {
    try {
      const {data} = await ticketService.getOne(id as string);
      setTicket(data.ticket);
      setQrCode(data.qrcode);
    } catch(error) {
      const err: Error = error as Error;
      const message = err?.message?? 'unknown server error';
      const toast: Toast = Toast.show(message, {
        duration: Toast.durations.LONG,
        textColor: 'red',
        backgroundColor: 'orange'
      });
      setTimeout(function hideToast() {
        Toast.hide(toast);
      }, 1500);
      router.back();
    }
  }
  useFocusEffect(useCallback(() => { fetchTicket()}, []));
  useEffect(()=>{
    navigation.setOptions({
      headerTitle: ''
    });
  }, [navigation])
  if (!ticket) return null;
  return (
    <VStack
      alignItems='center'
      m={20}
      p={20}
      gap={20}
      flex={1}
      style={{
        backgroundColor: 'white',
        borderRadius: 20,
      }}
    >
      <Text fontSize={50} bold>{ticket.event.name}</Text>
      <Text fontSize={20} bold>{ticket.event.location}</Text>
      <Text fontSize={16} color='gray'>{new Date(ticket.event.startDate).toLocaleString()}</Text>
      <Image
        style={{
          borderRadius: 20
        }} 
        width={300}
        height={300}
        source={{uri: `data:image/png;base64,${qrcode}`}}
      />
    </VStack>
  );
}

這邊載入畫面時,會先去透過 fetchTicket 載入單一 Ticket 詳細資料,並且回傳與顯示 qrcode 。而 qrcode 回傳資料主要是使用 base64 的字串,所以可以透過 Image 元件以 data:image/png;base64 格式顯示。

4. 驗證使用者 Ticket 操作流程

4.1 Buy Ticket
image
image

4.2 進入表列查看 買入的 Ticket

image

4.3 點入顯示 qrcode 頁面
image

Scan Ticket 的畫面與操作

這邊將會使用 expo-camera 元件來作 qrcode 掃描

1. 設定 expo camera

npx expo install expo-camera

2. 建立 CameraView 與內部邏輯

import { Button } from '@/components/Button';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { ticketService } from '@/services/ticket';
import { BarcodeScanningResult, CameraView, useCameraPermissions } from 'expo-camera';
import { router, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Vibration } from 'react-native';

export default function ScanTicketScreen() {
  const navigation = useNavigation();
  const [permssion, requestPermission] = useCameraPermissions();
  const [scanningEnabled, setScanningEnabled] = useState(true);
  useEffect(()=>{
    navigation.setOptions({
      headerTitle: 'Scan Ticket'
    })
  }, [navigation])
  if (!permssion) {
    return (
      <VStack flex={1} justifyContent='center' alignItems='center'>
        <ActivityIndicator size='large'/>
      </VStack>
    )
  }
  if (!permssion.granted) {
    return (
      <VStack gap={20} flex={1} justifyContent='center' alignItems='center'>
        <Text>Camera access is required to scan tickets.</Text>
        <Button onPress={requestPermission}>Allow Camera Access</Button>
      </VStack>
    )
  }
  async function onBarcodeScanned({data}: BarcodeScanningResult) {
    if (!scanningEnabled) return;
    try {
      Vibration.vibrate();
      setScanningEnabled(false);
      const jsonData = JSON.parse(data);
      const [ticketId, userId ] = [jsonData['ticket_id'], jsonData['user_id']];
      await ticketService.validateOne(ticketId, userId);
      Alert.alert('Success', `Ticket with id ${ticketId} validate successs`, [{
        text: 'Scan Next',
        onPress: () => {
          setScanningEnabled(true);
        }
      }]);
      router.back()
    } catch (error) {
      Alert.alert('Error', 'Failed to validate ticket please try again', [
        {
          text: 'Scan Next',
          onPress: () => {
            setScanningEnabled(true);
          }
        }
      ]);
    } 
  }
  return (
    <CameraView
      style={{flex: 1}}
      facing='back'
      onBarcodeScanned={onBarcodeScanned}
      barcodeScannerSettings={{barcodeTypes: ['qr']}}
    />
  );
}

這邊關鍵的函數在於 onBarcodeScanned 這個 callback 會把掃描完解析出來的結果放在 Data 的部份。然後在繼續值驗證的數。

驗證 Scan QrCode 功能

這邊由於只有真正的手機才能使用 Camera Scan 功能,所以需要在 Android 手機這邊使用 expo app 載入開發的程式,並且透過 ngrok 本機的 api 放入,讓手機也能夠連通道本機 server 或是使用之前放到 fly.io 上的 api。

  1. 使用者開啟 Qrcode 頁面
    image
  2. Admin 開啟掃描功能

image
image

到這裡,基本上基礎的訂票活動管理系統就已經完成。

結論

使用 nestjs 製作活動訂票系統,從前面規劃設計,實做到這篇。耗時最多的除了不擅長的 Mobile 元件狀態操控部份,最多就是在設計系統層面。有了適當的設計在後期不論做了那部份的調整都能透過版本控制來作行為差異化的檢查。

本來希望透過一個系統的實作來展現 nestjs 實戰時的作用,但後期由於 mobile app 的互動成份太多。導致整篇文章變成除了介紹了 nestjs ,最多還是 expo 開發。

以下附上我實做系統的部份 github 連結

  1. ticket-booking-system

  2. ticket-booking-app

希望這個系列,能夠幫助到需要使用 nestjs 當作後端 api 開發的人。


上一篇
nestjs 系統設計 - 活動訂票管理系統 - client code part 4
系列文
透過 nestjs 框架,讓 nodejs 系統維護度增加31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言