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 ( +
+ + + + + + + + + + + + {contacts.map((contact) => { + const contactError = errors[contact.id] || {}; + return ( + + + + + + + + ); + })} + +
NamePhoneEmailChannelRemove
+ 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}

+ )} +
+
+ + + +
+
+ ); +} 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 ( + <> + + {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 ? ( + + ) : ( +

+ Contact picker isn’t supported on this browser. Try the paste or share tab instead. +

+ )} + + +
+ )} + {activeTab === "share" && ( +
+

+ Use your device share sheet or copy the invite message. This keeps the existing “Copy invite link” flow intact. +

+
+
{inviteMessage}
+
+
+ {supportsShare && ( + + )} + + +
+ {copyFeedback && ( +

{copyFeedback}

+ )} + {shareQuickActions.length > 0 && ( +
+

+ Quick actions +

+
+ {shareQuickActions.map((action) => ( + + {action.icon} + {action.label} + + ))} +
+
+ )} +
+ )} + {activeTab === "paste" && ( +
+

+ Paste phone numbers or email addresses (one per line). We’ll validate, de-duplicate, and let you edit before sending. +

+