diff --git a/messages/en-US.json b/messages/en-US.json index 10f5b7464..078de88ae 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -73,7 +73,17 @@ "button": "Add by URL", "title": "Add a group by URL", "description": "If a group was shared with you, you can paste its URL here to add it to your list.", - "error": "Oops, we are not able to find the group from the URL you provided…" + "error": "Oops, we are not able to find the group from the URL you provided…", + "urlMode": "Enter URL", + "qrMode": "Scan QR", + "scanner": { + "startCamera": "Start Camera", + "stopScanning": "Stop Scanning", + "permissionDenied": "Camera permission denied. Please allow camera access to scan QR codes.", + "noCamera": "No camera found on this device.", + "cameraError": "Failed to start camera. Please try again.", + "permissionError": "Unable to access camera. Please check permissions." + } }, "NotFound": { "text": "This group does not exist.", @@ -347,7 +357,12 @@ "title": "Share", "description": "For other participants to see the group and add expenses, share its URL with them.", "warning": "Warning!", - "warningHelp": "Every person with the group URL will be able to see and edit expenses. Share with caution!" + "warningHelp": "Every person with the group URL will be able to see and edit expenses. Share with caution!", + "qrCode": { + "title": "Share via QR Code", + "description": "Scan this QR code to open the group on any device.", + "download": "Download QR Code" + } }, "SchemaErrors": { "min1": "Enter at least one character.", diff --git a/package-lock.json b/package-lock.json index 17f239100..697b3b987 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "content-disposition": "^0.5.4", "dayjs": "^1.11.10", "embla-carousel-react": "^8.6.0", + "html5-qrcode": "^2.3.8", "lucide-react": "^0.501.0", "nanoid": "^5.0.4", "negotiator": "^0.6.3", @@ -49,6 +50,7 @@ "openai": "^4.25.0", "pg": "^8.11.3", "prisma": "^6.18.0", + "qrcode.react": "^4.2.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-hook-form": "^7.68.0", @@ -14167,6 +14169,12 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/http-proxy-agent": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", @@ -16501,17 +16509,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next-intl/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -18051,6 +18048,15 @@ } ] }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", diff --git a/package.json b/package.json index 0d96648ae..45df50558 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "content-disposition": "^0.5.4", "dayjs": "^1.11.10", "embla-carousel-react": "^8.6.0", + "html5-qrcode": "^2.3.8", "lucide-react": "^0.501.0", "nanoid": "^5.0.4", "negotiator": "^0.6.3", @@ -57,6 +58,7 @@ "openai": "^4.25.0", "pg": "^8.11.3", "prisma": "^6.18.0", + "qrcode.react": "^4.2.0", "react": "^19.2.1", "react-dom": "^19.2.1", "react-hook-form": "^7.68.0", diff --git a/src/app/groups/[groupId]/share-button.tsx b/src/app/groups/[groupId]/share-button.tsx index 0b2d0009c..f19d2f176 100644 --- a/src/app/groups/[groupId]/share-button.tsx +++ b/src/app/groups/[groupId]/share-button.tsx @@ -1,5 +1,6 @@ 'use client' import { CopyButton } from '@/components/copy-button' +import { ShareQrCodeDialog } from '@/components/share-qr-code-dialog' import { ShareUrlButton } from '@/components/share-url-button' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -39,6 +40,7 @@ export function ShareButton({ group }: Props) { text={`Join my group ${group.name} on Spliit`} url={url} /> + )}

diff --git a/src/app/groups/add-group-by-url-button.tsx b/src/app/groups/add-group-by-url-button.tsx index da76ca43a..eb07ce4ad 100644 --- a/src/app/groups/add-group-by-url-button.tsx +++ b/src/app/groups/add-group-by-url-button.tsx @@ -1,4 +1,5 @@ import { saveRecentGroup } from '@/app/groups/recent-groups-helpers' +import { QrCodeScanner } from '@/components/qr-code-scanner' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -8,7 +9,7 @@ import { } from '@/components/ui/popover' import { useMediaQuery } from '@/lib/hooks' import { trpc } from '@/trpc/client' -import { Loader2, Plus } from 'lucide-react' +import { Loader2, Plus, QrCode, Link as LinkIcon } from 'lucide-react' import { useTranslations } from 'next-intl' import { useState } from 'react' @@ -21,10 +22,52 @@ export function AddGroupByUrlButton({ reload }: Props) { const isDesktop = useMediaQuery('(min-width: 640px)') const [url, setUrl] = useState('') const [error, setError] = useState(false) + const [scanError, setScanError] = useState('') const [open, setOpen] = useState(false) const [pending, setPending] = useState(false) + const [scanMode, setScanMode] = useState(false) const utils = trpc.useUtils() + const processUrl = async (urlToProcess: string) => { + const [, groupId] = + urlToProcess.match( + new RegExp(`${window.location.origin}/groups/([^/]+)`), + ) ?? + urlToProcess.match(/\/groups\/([^/?]+)/) ?? // Also match relative URLs from QR + [] + + if (!groupId) { + setError(true) + setScanError('') + setPending(false) + return + } + + setPending(true) + try { + const { group } = await utils.groups.get.fetch({ + groupId: groupId, + }) + if (group) { + saveRecentGroup({ id: group.id, name: group.name }) + reload() + setUrl('') + setOpen(false) + setScanMode(false) + setError(false) + setScanError('') + } else { + setError(true) + setScanError('') + } + } catch (err) { + setError(true) + setScanError('') + } finally { + setPending(false) + } + } + return ( @@ -36,50 +79,84 @@ export function AddGroupByUrlButton({ reload }: Props) { >

{t('title')}

{t('description')}

-
{ - event.preventDefault() - const [, groupId] = - url.match( - new RegExp(`${window.location.origin}/groups/([^/]+)`), - ) ?? [] - setPending(true) - const { group } = await utils.groups.get.fetch({ - groupId: groupId, - }) - if (group) { - saveRecentGroup({ id: group.id, name: group.name }) - reload() - setUrl('') - setOpen(false) - } else { - setError(true) - setPending(false) - } - }} - > - { - setUrl(event.target.value) + + {!isDesktop && ( +
+ + +
+ )} + + {!scanMode ? ( + { + event.preventDefault() + await processUrl(url) + }} + > + { + setUrl(event.target.value) + setError(false) + setScanError('') + }} + /> + +
+ ) : ( + { + processUrl(scannedUrl) + }} + onError={(errorMsg) => { + setScanError(errorMsg) setError(false) }} + onClose={() => setScanMode(false)} /> - - + )} + {error &&

{t('error')}

} + {scanError &&

{scanError}

} ) diff --git a/src/components/qr-code-scanner.tsx b/src/components/qr-code-scanner.tsx new file mode 100644 index 000000000..2cb5ee28d --- /dev/null +++ b/src/components/qr-code-scanner.tsx @@ -0,0 +1,120 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Html5Qrcode } from 'html5-qrcode' +import { Camera, X } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { useEffect, useId, useRef, useState } from 'react' + +interface Props { + onScan: (url: string) => void + onError?: (error: string) => void + onClose?: () => void +} + +export function QrCodeScanner({ onScan, onError, onClose }: Props) { + const t = useTranslations('Groups.AddByURL.scanner') + const [isScanning, setIsScanning] = useState(false) + const [hasPermission, setHasPermission] = useState(null) + const scannerRef = useRef(null) + const uniqueId = useId() + const elementId = `qr-reader-${uniqueId}` + + useEffect(() => { + return () => { + // Cleanup on unmount + if (scannerRef.current?.isScanning) { + scannerRef.current.stop().catch(console.error) + } + } + }, []) + + const startScanning = async () => { + try { + const html5QrCode = new Html5Qrcode(elementId) + scannerRef.current = html5QrCode + + await html5QrCode.start( + { facingMode: 'environment' }, + { + fps: 10, + qrbox: { width: 250, height: 250 }, + }, + (decodedText) => { + // Successfully scanned + onScan(decodedText) + stopScanning() + }, + (errorMessage) => { + // Scanning error - these happen frequently, so we don't report them + // unless we want to show detailed debugging + } + ) + + setIsScanning(true) + setHasPermission(true) + } catch (err: any) { + console.error('Error starting QR scanner:', err) + setHasPermission(false) + setIsScanning(false) + if (onError) { + if (err.name === 'NotAllowedError') { + onError(t('permissionDenied')) + } else if (err.name === 'NotFoundError') { + onError(t('noCamera')) + } else { + onError(t('cameraError')) + } + } + } + } + + const stopScanning = async () => { + if (scannerRef.current?.isScanning) { + try { + await scannerRef.current.stop() + } catch (err) { + console.error('Error stopping scanner:', err) + } + } + setIsScanning(false) + if (onClose) { + onClose() + } + } + + return ( +
+
+ + {!isScanning && hasPermission === null && ( + + )} + + {isScanning && ( + + )} + + {hasPermission === false && ( +
+ {t('permissionError')} +
+ )} +
+ ) +} diff --git a/src/components/share-qr-code-dialog.tsx b/src/components/share-qr-code-dialog.tsx new file mode 100644 index 000000000..3d1646448 --- /dev/null +++ b/src/components/share-qr-code-dialog.tsx @@ -0,0 +1,115 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { QrCode } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { QRCodeSVG } from 'qrcode.react' +import { useEffect, useId, useState } from 'react' + +interface Props { + url: string + groupName: string +} + +export function ShareQrCodeDialog({ url, groupName }: Props) { + const t = useTranslations('Share') + const qrCodeId = useId() + const [logoDataUrl, setLogoDataUrl] = useState('') + + useEffect(() => { + // Load the Spliit logo and convert it to a data URL + const loadLogo = async () => { + try { + const response = await fetch('/logo/192x192.png') + const blob = await response.blob() + const reader = new FileReader() + reader.onloadend = () => { + setLogoDataUrl(reader.result as string) + } + reader.readAsDataURL(blob) + } catch (error) { + console.error('Failed to load logo:', error) + } + } + loadLogo() + }, []) + + const handleDownload = () => { + const svg = document.getElementById(qrCodeId) + if (!svg) return + + const svgData = new XMLSerializer().serializeToString(svg) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const img = new Image() + + img.onload = () => { + canvas.width = img.width + canvas.height = img.height + ctx?.drawImage(img, 0, 0) + const pngFile = canvas.toDataURL('image/png') + + const downloadLink = document.createElement('a') + downloadLink.download = `${groupName}-qr-code.png` + downloadLink.href = pngFile + downloadLink.click() + } + + img.src = 'data:image/svg+xml;base64,' + btoa(svgData) + } + + return ( + + + + + + + {t('qrCode.title')} + {t('qrCode.description')} + +
+
+ +
+ +
+
+
+ ) +}