From 1c354346b692db6f0003da282f7e908a9b156afc Mon Sep 17 00:00:00 2001 From: Igor Date: Thu, 16 Jul 2026 16:14:43 +0200 Subject: [PATCH] feat: enhance TransferTicketModal with link/QR code support and improve user lookup functionality --- components/ActionPage/TransferTicketModal.tsx | 418 +++++++++++++++--- helpers/api.ts | 12 +- 2 files changed, 355 insertions(+), 75 deletions(-) diff --git a/components/ActionPage/TransferTicketModal.tsx b/components/ActionPage/TransferTicketModal.tsx index cc30a52..dc18a67 100644 --- a/components/ActionPage/TransferTicketModal.tsx +++ b/components/ActionPage/TransferTicketModal.tsx @@ -1,11 +1,11 @@ 'use client'; -import React, { useMemo, useState } from 'react'; -import { Check, Loader2, Search, Send, UserRound, X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Camera, Check, ImageUp, Link2, Loader2, Search, Send, UserRound, Users, X } from 'lucide-react'; import { Dialog, DialogContent } from '@/components/ui/dialog'; import { useAuthToken } from '@/hooks/use-auth-token'; import { useGetAcceptedContactsQuery } from '@/states/contactSlice'; -import { transferActionPurchase } from '@/helpers/api'; +import { transferActionPurchase, getUserById, getOrganizationById } from '@/helpers/api'; import { toast } from '@/hooks/use-toast'; interface TransferTicketModalProps { @@ -14,11 +14,12 @@ interface TransferTicketModalProps { /** The purchase to transfer (from the QR object's actionPurchaseId) */ purchaseId: string; ticketName: string; + /** The current owner — used to guard against transferring to yourself */ senderId: string; onTransferred: () => void; } -interface ContactUser { +interface RecipientUser { id: string; firstName: string; lastName: string; @@ -26,10 +27,36 @@ interface ContactUser { profile?: { profileImage?: string }; } -const fullName = (u: ContactUser) => `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email; -const initials = (u: ContactUser) => +type Mode = 'contacts' | 'link'; + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +const fullName = (u: RecipientUser) => `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email; +const initials = (u: RecipientUser) => `${(u.firstName || u.email || '?')[0] || ''}${(u.lastName || '')[0] || ''}`.toUpperCase(); +/** Pull a user id out of a profile link, full URL, or raw id — QR codes encode the same link. */ +const extractUserId = (input: string): string | null => { + const trimmed = input.trim(); + const welcomeMatch = trimmed.match(/\/welcome\/([^/?#]+)/i); + if (welcomeMatch) return welcomeMatch[1]; + const uuid = trimmed.match(UUID_RE); + return uuid ? uuid[0] : null; +}; + +const Avatar: React.FC<{ user: RecipientUser; size?: number }> = ({ user, size = 36 }) => ( +
+ {user.profile?.profileImage ? ( + + ) : ( + {initials(user)} + )} +
+); + const TransferTicketModal: React.FC = ({ open, onClose, @@ -40,18 +67,32 @@ const TransferTicketModal: React.FC = ({ }) => { const { getToken } = useAuthToken(); const token = getToken(); + const [mode, setMode] = useState('contacts'); const [search, setSearch] = useState(''); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const [submitting, setSubmitting] = useState(false); + // Link / QR mode + const [linkInput, setLinkInput] = useState(''); + const [resolving, setResolving] = useState(false); + const [resolveError, setResolveError] = useState(null); + const [resolved, setResolved] = useState(null); + const qrFileRef = useRef(null); + + // Live camera scanning + const [cameraActive, setCameraActive] = useState(false); + const [cameraError, setCameraError] = useState(null); + const videoRef = useRef(null); + const scannerRef = useRef(null); + const { data, isLoading, error } = useGetAcceptedContactsQuery(token as string, { skip: !token || !open, }); - const contacts: ContactUser[] = useMemo(() => { + const contacts: RecipientUser[] = useMemo(() => { const list = (data?.contacts || []) .map((c: any) => c.otherUser) - .filter((u: ContactUser | undefined): u is ContactUser => Boolean(u?.id)); + .filter((u: RecipientUser | undefined): u is RecipientUser => Boolean(u?.id)); if (!search.trim()) return list; const q = search.toLowerCase(); return list.filter( @@ -59,21 +100,144 @@ const TransferTicketModal: React.FC = ({ ); }, [data, search]); - const handleClose = () => { + const reset = () => { + stopCamera(); + setMode('contacts'); setSearch(''); setSelected(null); setSubmitting(false); + setLinkInput(''); + setResolving(false); + setResolveError(null); + setResolved(null); + setCameraError(null); + }; + + const handleClose = () => { + reset(); onClose(); }; + // Look up a recipient from a pasted link / id and preview them before transfer. + // A /welcome/:id link can point at a person or an organization, so try both. + const resolveRecipient = async (rawInput: string) => { + const id = extractUserId(rawInput); + setResolved(null); + setResolveError(null); + if (!id) { + setResolveError('Could not find a profile link or ID in that text.'); + return; + } + if (id === senderId) { + setResolveError('That is your own profile — pick someone else.'); + return; + } + try { + setResolving(true); + + // A person can receive a ticket + try { + const res = await getUserById(id); + const user = (res.data?.data ?? res.data) as RecipientUser; + if (user?.id) { + setResolved(user); + return; + } + } catch { /* fall through to the organization check */ } + + // An organization profile link is valid but can't own a personal ticket + try { + const orgRes = await getOrganizationById(id); + const org = orgRes.data?.data ?? orgRes.data; + if (org?.id) { + setResolveError('That link is an organization — tickets can only be sent to a person.'); + return; + } + } catch { /* neither user nor org */ } + + setResolveError('No account found for that link or QR code.'); + } finally { + setResolving(false); + } + }; + + const stopCamera = useCallback(() => { + if (scannerRef.current) { + try { scannerRef.current.stop(); scannerRef.current.destroy(); } catch { /* already gone */ } + scannerRef.current = null; + } + setCameraActive(false); + }, []); + + // Open the device camera and resolve the recipient from a scanned profile QR + const startCamera = async () => { + setCameraError(null); + setResolveError(null); + try { + const QrScanner = (await import('qr-scanner')).default; + if (!(await QrScanner.hasCamera())) { + setCameraError('No camera was found on this device.'); + return; + } + setCameraActive(true); + // Let the