Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
107 changes: 107 additions & 0 deletions src/features/invite/ContactsPreview.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { X, Phone, Mail } from "lucide-react";

export default function ContactsPreview({ contacts = [], errors = {}, onEdit, onRemove }) {
if (!contacts.length) {
return (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
No contacts yet. Add some from the tabs above.
</div>
);
}

return (
<div className="overflow-hidden rounded-xl border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-left text-sm">
<thead className="bg-gray-50 text-xs font-semibold uppercase tracking-wider text-gray-500">
<tr>
<th className="px-4 py-2">Name</th>
<th className="px-4 py-2">Phone</th>
<th className="px-4 py-2">Email</th>
<th className="px-4 py-2">Channel</th>
<th className="px-4 py-2 text-right">Remove</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{contacts.map((contact) => {
const contactError = errors[contact.id] || {};
return (
<tr key={contact.id} className="align-top">
<td className="px-4 py-3">
<input
type="text"
value={contact.name || ""}
onChange={(event) => 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"
/>
</td>
<td className="px-4 py-3">
<div className="space-y-1">
<div className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
contactError.phone
? "border-red-300 bg-red-50 text-red-700"
: "border-gray-200 text-gray-800"
}`}>
<Phone className="h-4 w-4 text-gray-400" />
<input
type="tel"
value={contact.phone || ""}
onChange={(event) => onEdit?.(contact.id, { phone: event.target.value })}
className="w-full bg-transparent text-sm outline-none"
placeholder="E.164"
/>
</div>
{contactError.phone && (
<p className="text-xs font-semibold text-red-600">{contactError.phone}</p>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="space-y-1">
<div className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
contactError.email
? "border-red-300 bg-red-50 text-red-700"
: "border-gray-200 text-gray-800"
}`}>
<Mail className="h-4 w-4 text-gray-400" />
<input
type="email"
value={contact.email || ""}
onChange={(event) => onEdit?.(contact.id, { email: event.target.value })}
className="w-full bg-transparent text-sm outline-none"
placeholder="name@example.com"
/>
</div>
{contactError.email && (
<p className="text-xs font-semibold text-red-600">{contactError.email}</p>
)}
</div>
</td>
<td className="px-4 py-3">
<select
value={contact.channel || (contact.phone ? "sms" : "email")}
onChange={(event) => 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"
>
<option value="sms">SMS</option>
<option value="email">Email</option>
</select>
</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => 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"
>
<X className="h-4 w-4" />
Remove
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
67 changes: 67 additions & 0 deletions src/features/invite/InviteButton.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<button
type="button"
onClick={handleOpen}
disabled={disabled || loading}
className={`inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
>
<Users className="h-4 w-4 text-white" />
{loading ? "Preparing…" : "Invite players"}
</button>
{error && !isOpen && (
<p className="mt-2 text-xs text-red-600">{error}</p>
)}
{isOpen && (
<InviteModal
match={match}
inviteUrl={inviteUrl}
onClose={handleClose}
onRefreshInviteUrl={getInviteUrl}
/>
)}
</>
);
}
Loading