diff --git a/README.md b/README.md
index d4012f6c..cefca6cb 100644
--- a/README.md
+++ b/README.md
@@ -59,3 +59,32 @@ This project can be hosted on GitHub Pages. Run the deploy script to build the s
```
npm run deploy
```
+
+## Invite Players flow
+
+The match details screen now includes an **Invite players** button for hosts. The feature opens a modal with multiple tabs that progressively enhance depending on browser capabilities:
+
+- **Device** – Uses the Contact Picker API (`navigator.contacts.select`) when available. Contacts are only requested after the host explicitly taps the picker button and are discarded when the modal closes.
+- **Share** – Uses the Web Share API if supported. It always exposes the existing _Copy invite link_ action and quick SMS/Email deep links as fallbacks.
+- **Paste** – Accepts phone numbers or emails (one per line), validates each entry, and lets the host edit before sending.
+- **Upload** – Parses CSV or VCF files client-side to bootstrap the contact list. Files are never uploaded to a server.
+
+All flows build the same short invite message:
+
+```
+Join my tennis match!
+When: {localDateTime}
+Where: {location}
+Level: {level}
+Join: {inviteUrl}
+```
+
+### Browser support
+
+- Contact Picker and Web Share tabs appear only when the corresponding API is detected.
+- SMS and email fallbacks use standard `sms:` and `mailto:` URLs to work on desktop and mobile.
+- The modal works over HTTPS and gracefully degrades to copy/paste if modern APIs are unavailable.
+
+### Optional server hook
+
+`src/features/invite/useContactInvites.ts` exports `sendServerInvite`. It is a no-op by default, but you can replace it with a thin adapter that posts invites to your backend if one exists.
diff --git a/package.json b/package.json
index 37de1008..129aaec2 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
+ "test": "node --test",
"preview": "vite preview",
"predeploy": "npm run build",
"deploy": "gh-pages -d dist"
diff --git a/src/features/invite/ContactsPreview.jsx b/src/features/invite/ContactsPreview.jsx
new file mode 100644
index 00000000..f07c4590
--- /dev/null
+++ b/src/features/invite/ContactsPreview.jsx
@@ -0,0 +1,107 @@
+import { X, Phone, Mail } from "lucide-react";
+
+export default function ContactsPreview({ contacts = [], errors = {}, onEdit, onRemove }) {
+ if (!contacts.length) {
+ return (
+
+ No contacts yet. Add some from the tabs above.
+
+ );
+ }
+
+ return (
+
+
+
+
+ Name
+ Phone
+ Email
+ Channel
+ Remove
+
+
+
+ {contacts.map((contact) => {
+ const contactError = errors[contact.id] || {};
+ return (
+
+
+ onEdit?.(contact.id, { name: event.target.value })}
+ placeholder="Optional"
+ className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-800 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
+ />
+
+
+
+
+
+
onEdit?.(contact.id, { phone: event.target.value })}
+ className="w-full bg-transparent text-sm outline-none"
+ placeholder="E.164"
+ />
+
+ {contactError.phone && (
+
{contactError.phone}
+ )}
+
+
+
+
+
+
+ onEdit?.(contact.id, { email: event.target.value })}
+ className="w-full bg-transparent text-sm outline-none"
+ placeholder="name@example.com"
+ />
+
+ {contactError.email && (
+
{contactError.email}
+ )}
+
+
+
+ onEdit?.(contact.id, { channel: event.target.value })}
+ className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-semibold text-gray-700 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
+ >
+ SMS
+ Email
+
+
+
+ onRemove?.(contact.id)}
+ className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-600 transition hover:bg-gray-50"
+ >
+
+ Remove
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/features/invite/InviteButton.jsx b/src/features/invite/InviteButton.jsx
new file mode 100644
index 00000000..e944415e
--- /dev/null
+++ b/src/features/invite/InviteButton.jsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Users } from "lucide-react";
+import InviteModal from "./InviteModal.jsx";
+
+const asyncNoop = async () => "";
+
+export default function InviteButton({
+ match,
+ getInviteUrl = asyncNoop,
+ disabled = false,
+ className = "",
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [inviteUrl, setInviteUrl] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleOpen = async () => {
+ if (disabled) return;
+ setError("");
+ setLoading(true);
+ try {
+ const url = await getInviteUrl();
+ if (typeof url === "string" && url.trim()) {
+ setInviteUrl(url.trim());
+ }
+ } catch (err) {
+ const message =
+ err?.message || "We couldn't load an invite link. Try again later.";
+ setError(message);
+ } finally {
+ setLoading(false);
+ setIsOpen(true);
+ }
+ };
+
+ const handleClose = () => {
+ setIsOpen(false);
+ setInviteUrl("");
+ setError("");
+ };
+
+ return (
+ <>
+
+
+ {loading ? "Preparing…" : "Invite players"}
+
+ {error && !isOpen && (
+ {error}
+ )}
+ {isOpen && (
+
+ )}
+ >
+ );
+}
diff --git a/src/features/invite/InviteModal.jsx b/src/features/invite/InviteModal.jsx
new file mode 100644
index 00000000..85a9fd01
--- /dev/null
+++ b/src/features/invite/InviteModal.jsx
@@ -0,0 +1,598 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ X,
+ Smartphone,
+ Share2,
+ ClipboardPaste,
+ UploadCloud,
+ Info,
+ Mail,
+ Phone,
+ Copy as CopyIcon,
+ Check,
+} from "lucide-react";
+import InviteTabs from "./InviteTabs.jsx";
+import ContactsPreview from "./ContactsPreview.jsx";
+import {
+ canUseContactPicker,
+ canUseWebShare,
+ dedupe,
+ isValidEmail,
+ isValidPhone,
+ mailtoLink,
+ parseFile,
+ parsePasted,
+ pickFromDevice,
+ shareInvite,
+ smsLink,
+ normalizePhone,
+ sendServerInvite,
+} from "./useContactInvites";
+
+const EMAIL_SUBJECT = "Join my tennis match";
+
+function formatDate(value) {
+ if (!value) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ return date.toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ });
+}
+
+function buildInviteMessage(match, url) {
+ const lines = ["Join my tennis match!"];
+ const when = match?.start_date_time || match?.dateTime;
+ const where = match?.location_text || match?.location;
+ const level = match?.skill_level || match?.skill_level_min || match?.level;
+
+ if (when) lines.push(`When: ${formatDate(when)}`);
+ if (where) lines.push(`Where: ${where}`);
+ if (level) lines.push(`Level: ${level}`);
+ lines.push(`Join: ${url}`);
+ return lines.join("\n");
+}
+
+function describeTab(tab) {
+ switch (tab) {
+ case "device":
+ return {
+ title: "Pick from your device",
+ icon: ,
+ };
+ case "share":
+ return {
+ title: "Share a link",
+ icon: ,
+ };
+ case "paste":
+ return {
+ title: "Paste contacts",
+ icon: ,
+ };
+ case "upload":
+ return {
+ title: "Upload CSV or VCF",
+ icon: ,
+ };
+ default:
+ return { title: "", icon: null };
+ }
+}
+
+export default function InviteModal({
+ match,
+ inviteUrl = "",
+ onClose,
+ onRefreshInviteUrl,
+}) {
+ const supportsContactPicker = canUseContactPicker();
+ const supportsShare = canUseWebShare();
+ const [activeTab, setActiveTab] = useState(
+ supportsContactPicker ? "device" : "share",
+ );
+ const [contacts, setContacts] = useState([]);
+ const [contactErrors, setContactErrors] = useState({});
+ const [status, setStatus] = useState("");
+ const [copyFeedback, setCopyFeedback] = useState("");
+ const [linkError, setLinkError] = useState("");
+ const [loadingLink, setLoadingLink] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [localInviteUrl, setLocalInviteUrl] = useState(inviteUrl);
+ const [pastedText, setPastedText] = useState("");
+
+ useEffect(() => {
+ setLocalInviteUrl(inviteUrl);
+ }, [inviteUrl]);
+
+ useEffect(() => {
+ return () => {
+ setContacts([]);
+ setPastedText("");
+ };
+ }, []);
+
+ const tabs = useMemo(() => {
+ const list = [
+ supportsContactPicker
+ ? { id: "device", label: "Device" }
+ : null,
+ { id: "share", label: "Share" },
+ { id: "paste", label: "Paste" },
+ { id: "upload", label: "Upload" },
+ ].filter(Boolean);
+ return list;
+ }, [supportsContactPicker]);
+
+ const inviteMessage = useMemo(() => {
+ const url = localInviteUrl || inviteUrl;
+ if (!url) return "Join my tennis match!";
+ return buildInviteMessage(match, url);
+ }, [inviteUrl, localInviteUrl, match]);
+
+ const ensureInviteUrl = useCallback(async () => {
+ if (localInviteUrl) return localInviteUrl;
+ if (!onRefreshInviteUrl) return "";
+ setLoadingLink(true);
+ setLinkError("");
+ try {
+ const next = await onRefreshInviteUrl();
+ if (typeof next === "string" && next.trim()) {
+ setLocalInviteUrl(next.trim());
+ return next.trim();
+ }
+ setLinkError("We couldn't load an invite link. Copy the link manually.");
+ return "";
+ } catch (error) {
+ setLinkError(
+ error?.message || "We couldn't load an invite link right now.",
+ );
+ return "";
+ } finally {
+ setLoadingLink(false);
+ }
+ }, [localInviteUrl, onRefreshInviteUrl]);
+
+ const mergeContacts = useCallback((incoming) => {
+ if (!incoming?.length) return;
+ setContacts((prev) => dedupe([...prev, ...incoming]));
+ }, []);
+
+ const handlePick = useCallback(async () => {
+ const picked = await pickFromDevice();
+ mergeContacts(picked);
+ setStatus(
+ picked.length
+ ? `Added ${picked.length} contact${picked.length === 1 ? "" : "s"}.`
+ : "No contacts selected.",
+ );
+ }, [mergeContacts]);
+
+ const handlePaste = useCallback(() => {
+ const parsed = parsePasted(pastedText);
+ mergeContacts(parsed);
+ setPastedText("");
+ setStatus(
+ parsed.length
+ ? `Added ${parsed.length} contact${parsed.length === 1 ? "" : "s"}.`
+ : "No valid contacts found.",
+ );
+ }, [mergeContacts, pastedText]);
+
+ const handleFile = useCallback(
+ async (event) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ const parsed = await parseFile(file);
+ mergeContacts(parsed);
+ event.target.value = "";
+ setStatus(
+ parsed.length
+ ? `Added ${parsed.length} contact${parsed.length === 1 ? "" : "s"}.`
+ : "We couldn't read any contacts from that file.",
+ );
+ },
+ [mergeContacts],
+ );
+
+ const updateContact = useCallback((id, updates) => {
+ setContacts((prev) =>
+ prev.map((contact) =>
+ contact.id === id
+ ? { ...contact, ...updates }
+ : contact,
+ ),
+ );
+ setContactErrors((prev) => {
+ if (!prev[id]) return prev;
+ const next = { ...prev };
+ const entry = { ...next[id] };
+ Object.keys(updates).forEach((key) => {
+ delete entry[key];
+ });
+ if (Object.keys(entry).length === 0) {
+ delete next[id];
+ } else {
+ next[id] = entry;
+ }
+ return next;
+ });
+ }, []);
+
+ const removeContact = useCallback((id) => {
+ setContacts((prev) => prev.filter((contact) => contact.id !== id));
+ }, []);
+
+ const validateContacts = useCallback((list) => {
+ const errors = {};
+ list.forEach((contact) => {
+ if (contact.channel === "sms") {
+ const normalized = contact.phone
+ ? normalizePhone(contact.phone)
+ : "";
+ if (!isValidPhone(normalized)) {
+ errors[contact.id] = {
+ ...(errors[contact.id] || {}),
+ phone: "Enter a valid phone number",
+ };
+ }
+ } else if (contact.channel === "email") {
+ if (!isValidEmail(contact.email)) {
+ errors[contact.id] = {
+ ...(errors[contact.id] || {}),
+ email: "Enter a valid email",
+ };
+ }
+ }
+ });
+ setContactErrors(errors);
+ return Object.keys(errors).length === 0;
+ }, []);
+
+ const openIntentLinks = useCallback((list, message, url) => {
+ list.forEach((contact, index) => {
+ const body = message;
+ if (contact.channel === "sms" && contact.phone) {
+ const intent = smsLink(contact.phone, body);
+ setTimeout(() => {
+ window.open(intent, "_blank", "noopener,noreferrer");
+ }, index * 100);
+ }
+ if (contact.channel === "email" && contact.email) {
+ const intent = mailtoLink(contact.email, EMAIL_SUBJECT, body);
+ setTimeout(() => {
+ window.open(intent, "_blank", "noopener,noreferrer");
+ }, index * 100);
+ }
+ void sendServerInvite(contact, { inviteUrl: url, body });
+ });
+ }, []);
+
+ const handleSend = useCallback(async () => {
+ if (!contacts.length) {
+ setStatus("Add at least one contact before sending.");
+ return;
+ }
+ if (!validateContacts(contacts)) {
+ setStatus("Fix the highlighted contact details first.");
+ return;
+ }
+ const sanitizedContacts = contacts.map((contact) => {
+ if (contact.channel === "sms") {
+ const normalized = contact.phone ? normalizePhone(contact.phone) : "";
+ return { ...contact, phone: normalized };
+ }
+ if (contact.channel === "email" && contact.email) {
+ return { ...contact, email: contact.email.trim().toLowerCase() };
+ }
+ return contact;
+ });
+ setContacts(sanitizedContacts);
+ setSending(true);
+ const url = await ensureInviteUrl();
+ const link = url || localInviteUrl || inviteUrl;
+ const message = buildInviteMessage(match, link);
+ if (!link) {
+ setStatus("We need an invite link before we can share.");
+ setSending(false);
+ return;
+ }
+ openIntentLinks(sanitizedContacts, message, link);
+ setStatus(
+ "We opened your messaging apps in new tabs. Complete the invites there.",
+ );
+ setSending(false);
+ }, [contacts, ensureInviteUrl, inviteUrl, localInviteUrl, match, openIntentLinks, validateContacts]);
+
+ const handleShare = useCallback(async () => {
+ const url = await ensureInviteUrl();
+ const link = url || localInviteUrl || inviteUrl;
+ if (!link) {
+ setStatus("We need an invite link before we can share.");
+ return;
+ }
+ const message = buildInviteMessage(match, link);
+ if (supportsShare) {
+ try {
+ await shareInvite(link, message);
+ setStatus("Shared! Choose an app from your device's share sheet.");
+ return;
+ } catch (error) {
+ console.warn("Share failed", error);
+ }
+ }
+ try {
+ await navigator.clipboard.writeText(message);
+ setCopyFeedback("Invite copied to clipboard.");
+ } catch (error) {
+ console.warn("Copy failed", error);
+ setCopyFeedback("Copy failed. Use the buttons below instead.");
+ }
+ }, [ensureInviteUrl, inviteUrl, localInviteUrl, match, supportsShare]);
+
+ const handleCopyLink = useCallback(async () => {
+ const link = localInviteUrl || inviteUrl;
+ if (!link) {
+ setStatus("Generate an invite link first.");
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(link);
+ setCopyFeedback("Link copied to clipboard.");
+ } catch (error) {
+ console.warn("Copy failed", error);
+ setCopyFeedback("Copy failed. Copy it manually instead.");
+ }
+ }, [inviteUrl, localInviteUrl]);
+
+ const shareQuickActions = useMemo(() => {
+ const link = localInviteUrl || inviteUrl;
+ if (!link) return [];
+ const message = buildInviteMessage(match, link);
+ const phoneIntent = smsLink("", message);
+ const emailIntent = mailtoLink("", EMAIL_SUBJECT, message);
+ return [
+ {
+ label: "Text message",
+ href: phoneIntent,
+ icon: ,
+ },
+ {
+ label: "Email",
+ href: emailIntent,
+ icon: ,
+ },
+ ];
+ }, [inviteUrl, localInviteUrl, match]);
+
+ const { title, icon } = describeTab(activeTab);
+
+ return (
+
+
+
+
+
+
+
+ Invite players
+
+ Share your match
+
+ Send a modern invite without sharing your full address book. Pick from your contacts, share a link, or paste details.
+
+
+
+
+
+ {icon}
+ {title}
+
+ {linkError && (
+
+ {linkError}
+
+ )}
+ {status && (
+
+
+ {status}
+
+ )}
+ {activeTab === "device" && (
+
+
+
+
+ We only access the contacts you pick. Nothing is stored after you close this window.
+
+
+ {supportsContactPicker ? (
+
+
+ Pick contacts
+
+ ) : (
+
+ Contact picker isn’t supported on this browser. Try the paste or share tab instead.
+
+ )}
+
+
+
+ {sending ? "Opening invites…" : "Send invites"}
+
+
+ )}
+ {activeTab === "share" && (
+
+
+ Use your device share sheet or copy the invite message. This keeps the existing “Copy invite link” flow intact.
+
+
+
+ {supportsShare && (
+
+
+ Share from device
+
+ )}
+
+
+ Copy invite link
+
+
+
+ Copy invite message
+
+
+ {copyFeedback && (
+
{copyFeedback}
+ )}
+ {shareQuickActions.length > 0 && (
+
+ )}
+
+ )}
+ {activeTab === "paste" && (
+
+
+ Paste phone numbers or email addresses (one per line). We’ll validate, de-duplicate, and let you edit before sending.
+
+
+ )}
+ {activeTab === "upload" && (
+
+
+ Upload a CSV (name, phone, email) or VCF contact file. We’ll only keep contacts for this session.
+
+
+
+ Choose a file
+ CSV or VCF up to 1MB
+
+
+
+
+
+ {sending ? "Opening invites…" : "Send invites"}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/features/invite/InviteTabs.jsx b/src/features/invite/InviteTabs.jsx
new file mode 100644
index 00000000..7f1562ad
--- /dev/null
+++ b/src/features/invite/InviteTabs.jsx
@@ -0,0 +1,24 @@
+export default function InviteTabs({ tabs, activeTab, onChange, className = "" }) {
+ if (!tabs?.length) return null;
+ return (
+
+ {tabs.map((tab) => {
+ const isActive = tab.id === activeTab;
+ return (
+ onChange(tab.id)}
+ className={`rounded-full px-4 py-1.5 transition ${
+ isActive
+ ? "bg-emerald-600 text-white shadow"
+ : "bg-gray-100 text-gray-600 hover:bg-gray-200"
+ }`}
+ >
+ {tab.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/features/invite/useContactInvites.js b/src/features/invite/useContactInvites.js
new file mode 100644
index 00000000..6cce8603
--- /dev/null
+++ b/src/features/invite/useContactInvites.js
@@ -0,0 +1,325 @@
+// @ts-check
+
+/**
+ * @typedef {Object} Contact
+ * @property {string} [id]
+ * @property {string} [name]
+ * @property {string} [phone]
+ * @property {string} [email]
+ * @property {'sms' | 'email'} [channel]
+ */
+
+const EMAIL_REGEX = /^(?:[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;
+
+export function canUseContactPicker() {
+ return (
+ typeof navigator !== "undefined" &&
+ "contacts" in navigator &&
+ typeof /** @type {any} */ (navigator).contacts?.select === "function"
+ );
+}
+
+export function canUseWebShare() {
+ return typeof navigator !== "undefined" && typeof navigator.share === "function";
+}
+
+const US_COUNTRY_CODE = "+1";
+
+export function normalizePhone(raw) {
+ if (!raw) return "";
+ let value = raw.trim();
+ if (!value) return "";
+ value = value.replace(/[^+\d]/g, "");
+ if (!value) return "";
+ if (value.startsWith("+")) {
+ const digits = value.slice(1).replace(/\D/g, "");
+ return digits ? `+${digits}` : "";
+ }
+ const digitsOnly = value.replace(/\D/g, "");
+ if (!digitsOnly) return "";
+ if (digitsOnly.length === 11 && digitsOnly.startsWith("1")) {
+ return `+${digitsOnly}`;
+ }
+ if (digitsOnly.length === 10) {
+ return `${US_COUNTRY_CODE}${digitsOnly}`;
+ }
+ return `+${digitsOnly}`;
+}
+
+/**
+ * @param {Contact[]} contacts
+ * @returns {Contact[]}
+ */
+export function dedupe(contacts) {
+ const seenPhones = new Map();
+ const seenEmails = new Map();
+ const result = [];
+
+ contacts.forEach((contact) => {
+ const phoneKey = contact.phone ? normalizePhone(contact.phone) : "";
+ const emailKey = contact.email ? contact.email.trim().toLowerCase() : "";
+ const hasPhone = Boolean(phoneKey);
+ const hasEmail = Boolean(emailKey);
+
+ if (hasPhone && seenPhones.has(phoneKey)) {
+ return;
+ }
+ if (hasEmail && seenEmails.has(emailKey)) {
+ return;
+ }
+
+ const normalized = {
+ ...contact,
+ phone: phoneKey || undefined,
+ email: emailKey || undefined,
+ };
+
+ if (hasPhone) {
+ seenPhones.set(phoneKey, normalized);
+ }
+ if (hasEmail) {
+ seenEmails.set(emailKey, normalized);
+ }
+
+ result.push(normalized);
+ });
+
+ return result;
+}
+
+const buildId = (() => {
+ let counter = 0;
+ return () => {
+ counter += 1;
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
+ try {
+ return crypto.randomUUID();
+ } catch (error) {
+ // Ignore and fall back to counter based id
+ }
+ }
+ return `contact-${Date.now()}-${counter}`;
+ };
+})();
+
+/**
+ * @returns {Promise}
+ */
+export async function pickFromDevice() {
+ if (!canUseContactPicker()) return [];
+ try {
+ const navigatorAny = /** @type {any} */ (navigator);
+ const results = await navigatorAny.contacts.select(["name", "tel", "email"], {
+ multiple: true,
+ });
+ if (!Array.isArray(results)) return [];
+
+ return dedupe(
+ results.map((entry) => {
+ const name = Array.isArray(entry.name) ? entry.name[0] : entry.name;
+ const tel = Array.isArray(entry.tel) ? entry.tel[0] : entry.tel;
+ const email = Array.isArray(entry.email) ? entry.email[0] : entry.email;
+ const normalizedPhone = normalizePhone(typeof tel === "string" ? tel : "");
+ const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : "";
+ return {
+ id: buildId(),
+ name: typeof name === "string" ? name.trim() : undefined,
+ phone: normalizedPhone || undefined,
+ email: normalizedEmail || undefined,
+ channel: normalizedPhone ? "sms" : "email",
+ };
+ }),
+ );
+ } catch (error) {
+ console.warn("Contact picker failed", error);
+ return [];
+ }
+}
+
+/**
+ * @param {string} text
+ * @returns {Contact[]}
+ */
+export function parsePasted(text) {
+ if (!text) return [];
+ const lines = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
+ const contacts = lines
+ .map((line) => {
+ if (EMAIL_REGEX.test(line)) {
+ return {
+ id: buildId(),
+ email: line.toLowerCase(),
+ channel: "email",
+ };
+ }
+ const phone = normalizePhone(line);
+ if (phone) {
+ return {
+ id: buildId(),
+ phone,
+ channel: "sms",
+ };
+ }
+ return null;
+ })
+ .filter(Boolean);
+
+ return dedupe(/** @type {Contact[]} */ (contacts));
+}
+
+/**
+ * @param {string} text
+ * @returns {Contact[]}
+ */
+function parseCsv(text) {
+ const rows = text
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ if (!rows.length) return [];
+
+ const [firstRow, ...rest] = rows;
+ const header = firstRow.split(/,|;|\t/).map((value) => value.trim().toLowerCase());
+ const hasHeader = header.some((value) => ["name", "phone", "tel", "email"].includes(value));
+ const dataRows = hasHeader ? rest : rows;
+ const effectiveHeader = hasHeader
+ ? header
+ : ["name", "phone", "email"].slice(0, rows[0].split(/,|;|\t/).length);
+
+ return dedupe(
+ /** @type {Contact[]} */ (
+ dataRows
+ .map((row) => {
+ const cells = row.split(/,|;|\t/).map((cell) => cell.trim());
+ const contact = { id: buildId() };
+ cells.forEach((cell, index) => {
+ const key = effectiveHeader[index];
+ if (!key) return;
+ if (!cell) return;
+ if (key.includes("name")) contact.name = cell;
+ if (key.includes("phone") || key.includes("tel")) {
+ const normalized = normalizePhone(cell);
+ if (normalized) contact.phone = normalized;
+ }
+ if (key.includes("mail")) contact.email = cell.toLowerCase();
+ });
+ if (!contact.phone && !contact.email) return null;
+ contact.channel = contact.phone ? "sms" : "email";
+ return contact;
+ })
+ .filter(Boolean)
+ ),
+ );
+}
+
+/**
+ * @param {string} text
+ * @returns {Contact[]}
+ */
+function parseVcf(text) {
+ const cards = text.split(/END:VCARD/i);
+ const contacts = [];
+ cards.forEach((card) => {
+ if (!/BEGIN:VCARD/i.test(card)) return;
+ const lines = card.split(/\r?\n/);
+ const contact = { id: buildId() };
+ lines.forEach((line) => {
+ const [rawKey, rawValue] = line.split(":");
+ if (!rawValue) return;
+ const key = rawKey.toUpperCase();
+ const value = rawValue.trim();
+ if (key.startsWith("FN")) contact.name = value;
+ if (key.startsWith("TEL")) {
+ const normalized = normalizePhone(value);
+ if (normalized && !contact.phone) contact.phone = normalized;
+ }
+ if (key.startsWith("EMAIL") && !contact.email) {
+ contact.email = value.toLowerCase();
+ }
+ });
+ if (!contact.phone && !contact.email) return;
+ contact.channel = contact.phone ? "sms" : "email";
+ contacts.push(contact);
+ });
+ return dedupe(contacts);
+}
+
+/**
+ * @param {File} file
+ * @returns {Promise}
+ */
+export async function parseFile(file) {
+ if (!file) return [];
+ const text = await file.text();
+ if (!text) return [];
+ if (/BEGIN:VCARD/i.test(text)) {
+ return parseVcf(text);
+ }
+ return parseCsv(text);
+}
+
+/**
+ * @param {string} url
+ * @param {string} text
+ */
+export async function shareInvite(url, text) {
+ if (!canUseWebShare()) {
+ throw new Error("Web Share API not available");
+ }
+ await navigator.share({ url, text, title: "Join my tennis match" });
+}
+
+/**
+ * @param {string} phone
+ * @param {string} body
+ */
+export function smsLink(phone, body) {
+ const sanitized = phone.replace(/[^\d+]/g, "");
+ const encodedBody = encodeURIComponent(body);
+ const base = sanitized ? `sms:${sanitized}` : "sms:";
+ const separator = base.includes("?") ? "&" : "?";
+ return `${base}${separator}body=${encodedBody}`;
+}
+
+/**
+ * @param {string} email
+ * @param {string} subject
+ * @param {string} body
+ */
+export function mailtoLink(email, subject, body) {
+ const encodedSubject = encodeURIComponent(subject);
+ const encodedBody = encodeURIComponent(body);
+ return `mailto:${encodeURIComponent(email)}?subject=${encodedSubject}&body=${encodedBody}`;
+}
+
+/**
+ * @param {Contact} contact
+ * @param {any} payload
+ */
+export async function sendServerInvite(contact, payload) {
+ // TODO(optional): wire this to a backend invite endpoint.
+ void contact;
+ void payload;
+}
+
+/**
+ * @param {string | undefined} value
+ */
+export function isValidEmail(value) {
+ if (!value) return false;
+ return EMAIL_REGEX.test(value.trim());
+}
+
+/**
+ * @param {string | undefined} value
+ */
+export function isValidPhone(value) {
+ const normalized = value ? normalizePhone(value) : "";
+ return Boolean(normalized && /^\+[1-9]\d{6,14}$/.test(normalized));
+}
+
+export { EMAIL_REGEX };
diff --git a/src/features/invite/useContactInvites.test.js b/src/features/invite/useContactInvites.test.js
new file mode 100644
index 00000000..ee20d6b6
--- /dev/null
+++ b/src/features/invite/useContactInvites.test.js
@@ -0,0 +1,71 @@
+import { strict as assert } from "node:assert";
+import { describe, it } from "node:test";
+import {
+ dedupe,
+ normalizePhone,
+ parseFile,
+ parsePasted,
+} from "./useContactInvites.js";
+
+describe("normalizePhone", () => {
+ it("formats US numbers to E.164", () => {
+ assert.equal(normalizePhone("(555) 123-4567"), "+15551234567");
+ });
+
+ it("keeps international prefix", () => {
+ assert.equal(normalizePhone("+44 20 7946 0958"), "+442079460958");
+ });
+
+ it("returns empty string for invalid", () => {
+ assert.equal(normalizePhone(""), "");
+ assert.equal(normalizePhone("abc"), "");
+ });
+});
+
+describe("dedupe", () => {
+ it("keeps the first occurrence of duplicate phones and emails", () => {
+ const contacts = dedupe([
+ { id: "1", phone: "+15551234567", name: "First" },
+ { id: "2", phone: "5551234567", name: "Duplicate" },
+ { id: "3", email: "friend@example.com" },
+ { id: "4", email: "Friend@example.com" },
+ ]);
+ assert.equal(contacts.length, 2);
+ assert.equal(contacts[0].id, "1");
+ assert.equal(contacts[1].id, "3");
+ });
+});
+
+describe("parsePasted", () => {
+ it("parses numbers and emails and de-dupes", () => {
+ const contacts = parsePasted(
+ "+1 555-123-4567\nfriend@example.com\n+15551234567",
+ );
+ assert.equal(contacts.length, 2);
+ const phoneContact = contacts.find((contact) => contact.phone);
+ const emailContact = contacts.find((contact) => contact.email);
+ assert.equal(phoneContact?.phone, "+15551234567");
+ assert.equal(emailContact?.email, "friend@example.com");
+ });
+});
+
+describe("parseFile", () => {
+ it("parses CSV rows", async () => {
+ const csv = "name,phone,email\nAlex,+1 555 123 4567,alex@example.com\nJamie,,jamie@example.com";
+ const file = new File([csv], "contacts.csv", { type: "text/csv" });
+ const contacts = await parseFile(file);
+ assert.equal(contacts.length, 2);
+ assert.equal(contacts[0].name, "Alex");
+ assert.equal(contacts[0].phone, "+15551234567");
+ assert.equal(contacts[1].email, "jamie@example.com");
+ });
+
+ it("parses VCF entries", async () => {
+ const vcf = `BEGIN:VCARD\nFN:Casey Jones\nTEL:+1-555-987-6543\nEMAIL:casey@example.com\nEND:VCARD`;
+ const file = new File([vcf], "contacts.vcf", { type: "text/vcard" });
+ const contacts = await parseFile(file);
+ assert.equal(contacts.length, 1);
+ assert.equal(contacts[0].phone, "+15559876543");
+ assert.equal(contacts[0].email, "casey@example.com");
+ });
+});
diff --git a/src/features/invite/useContactInvites.ts b/src/features/invite/useContactInvites.ts
new file mode 100644
index 00000000..1ed96dac
--- /dev/null
+++ b/src/features/invite/useContactInvites.ts
@@ -0,0 +1,47 @@
+import {
+ canUseContactPicker as implCanUseContactPicker,
+ canUseWebShare as implCanUseWebShare,
+ dedupe as implDedupe,
+ isValidEmail as implIsValidEmail,
+ isValidPhone as implIsValidPhone,
+ mailtoLink as implMailtoLink,
+ normalizePhone as implNormalizePhone,
+ parseFile as implParseFile,
+ parsePasted as implParsePasted,
+ pickFromDevice as implPickFromDevice,
+ shareInvite as implShareInvite,
+ smsLink as implSmsLink,
+ sendServerInvite as implSendServerInvite,
+} from "./useContactInvites.js";
+
+export type Contact = {
+ id?: string;
+ name?: string;
+ phone?: string;
+ email?: string;
+ channel?: "sms" | "email";
+};
+
+export const canUseContactPicker: () => boolean = implCanUseContactPicker;
+export const canUseWebShare: () => boolean = implCanUseWebShare;
+export const normalizePhone: (raw: string) => string = implNormalizePhone;
+export const dedupe: (contacts: Contact[]) => Contact[] = implDedupe;
+export const pickFromDevice: () => Promise = implPickFromDevice;
+export const parsePasted: (text: string) => Contact[] = implParsePasted;
+export const parseFile: (file: File) => Promise = implParseFile;
+export const shareInvite: (url: string, text: string) => Promise =
+ implShareInvite;
+export const smsLink: (phone: string, body: string) => string = implSmsLink;
+export const mailtoLink: (
+ email: string,
+ subject: string,
+ body: string,
+) => string = implMailtoLink;
+export const sendServerInvite: (
+ contact: Contact,
+ payload: any,
+) => Promise = implSendServerInvite;
+export const isValidEmail: (value: string | undefined) => boolean =
+ implIsValidEmail;
+export const isValidPhone: (value: string | undefined) => boolean =
+ implIsValidPhone;
diff --git a/src/pages/MatchPage.jsx b/src/pages/MatchPage.jsx
index baecd01b..7ddc781c 100644
--- a/src/pages/MatchPage.jsx
+++ b/src/pages/MatchPage.jsx
@@ -1,5 +1,5 @@
// src/pages/MatchPage.jsx
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
useMutation,
@@ -45,6 +45,7 @@ import {
import { combineDateAndTimeToIso } from "../utils/datetime";
import { isPrivateMatch } from "../utils/matchPrivacy";
import { buildMatchUpdatePayload } from "../utils/matchPayload";
+import InviteButton from "../features/invite/InviteButton.jsx";
const DEFAULT_FORM = {
date: "",
@@ -121,6 +122,7 @@ export default function MatchPage() {
const [shareCopied, setShareCopied] = useState(false);
const [removingId, setRemovingId] = useState(null);
const googleApiKey = import.meta.env.VITE_GOOGLE_API_KEY;
+ const inviteLinkRef = useRef("");
const [currentUser] = useState(() => {
try {
@@ -283,6 +285,27 @@ export default function MatchPage() {
},
});
+ useEffect(() => {
+ if (shareLink) {
+ inviteLinkRef.current = shareLink;
+ }
+ }, [shareLink]);
+
+ const getInviteLinkForModal = useCallback(async () => {
+ if (!match?.id) {
+ throw new Error("Match is still loading.");
+ }
+ if (inviteLinkRef.current) return inviteLinkRef.current;
+ const response = await getShareLink(match.id);
+ const link =
+ response?.shareUrl || response?.share_url || response?.url || "";
+ if (!link) {
+ throw new Error("We couldn't generate an invite link.");
+ }
+ inviteLinkRef.current = link;
+ return link;
+ }, [match?.id]);
+
const handleEditToggle = () => {
if (!canEdit) return;
setFeedback(null);
@@ -770,13 +793,19 @@ export default function MatchPage() {
Use the invite flow to add players directly.
-
-
- Open invite manager
-
+
+
+
+
+ Open invite manager
+
+
)}