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 (
{t('description')} {t('error')} {scanError}{t('title')}