143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
|
|
// components/ShareLinkButton.tsx
|
||
|
|
// Renders the public onboarding URL with a native-share / copy-to-clipboard
|
||
|
|
// affordance. On mobile we use navigator.share() so the user can fire the
|
||
|
|
// link off via WhatsApp / SMS / email / any installed share target. On
|
||
|
|
// desktop (or any browser without navigator.share) we fall back to a
|
||
|
|
// "Copy link" button. After success we show a transient confirmation
|
||
|
|
// ("Copied" / "Sent") for ~2s and then revert to the original label.
|
||
|
|
|
||
|
|
"use client";
|
||
|
|
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
|
||
|
|
type ShareState = "idle" | "copied" | "shared" | "error";
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
/** The full URL the user should send to the client. */
|
||
|
|
url: string;
|
||
|
|
/** Optional share-sheet title. Defaults to "Workflow onboarding link". */
|
||
|
|
title?: string;
|
||
|
|
/** Optional one-liner that ships in the share sheet body where
|
||
|
|
* the platform supports it. */
|
||
|
|
text?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ShareLinkButton({
|
||
|
|
url,
|
||
|
|
title = "Workflow onboarding link",
|
||
|
|
text = "Please complete your company and director information using this secure link:"
|
||
|
|
}: Props) {
|
||
|
|
const [state, setState] = useState<ShareState>("idle");
|
||
|
|
const [canNativeShare, setCanNativeShare] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
// navigator.share is mobile-first; some desktop browsers have it
|
||
|
|
// behind a flag. We render the native button optimistically when
|
||
|
|
// present (the type narrowing avoids SSR-mismatch issues since
|
||
|
|
// we only set the flag in an effect).
|
||
|
|
if (
|
||
|
|
typeof navigator !== "undefined" &&
|
||
|
|
typeof navigator.share === "function"
|
||
|
|
) {
|
||
|
|
setCanNativeShare(true);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
async function copyToClipboard() {
|
||
|
|
try {
|
||
|
|
// Modern path with Clipboard API; falls back to the legacy
|
||
|
|
// textarea-trick if the secure API isn't allowed (HTTP context).
|
||
|
|
if (navigator.clipboard?.writeText) {
|
||
|
|
await navigator.clipboard.writeText(url);
|
||
|
|
} else {
|
||
|
|
const ta = document.createElement("textarea");
|
||
|
|
ta.value = url;
|
||
|
|
ta.setAttribute("readonly", "");
|
||
|
|
ta.style.position = "fixed";
|
||
|
|
ta.style.opacity = "0";
|
||
|
|
document.body.appendChild(ta);
|
||
|
|
ta.select();
|
||
|
|
document.execCommand("copy");
|
||
|
|
document.body.removeChild(ta);
|
||
|
|
}
|
||
|
|
setState("copied");
|
||
|
|
} catch {
|
||
|
|
setState("error");
|
||
|
|
}
|
||
|
|
window.setTimeout(() => setState("idle"), 1800);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function shareNative() {
|
||
|
|
try {
|
||
|
|
await navigator.share({ url, title, text });
|
||
|
|
setState("shared");
|
||
|
|
} catch (err) {
|
||
|
|
// User-cancelled share is NOT an error — silent revert.
|
||
|
|
if ((err as DOMException)?.name === "AbortError") {
|
||
|
|
setState("idle");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setState("error");
|
||
|
|
}
|
||
|
|
window.setTimeout(() => setState("idle"), 1800);
|
||
|
|
}
|
||
|
|
|
||
|
|
const label =
|
||
|
|
state === "copied"
|
||
|
|
? "Copied ✓"
|
||
|
|
: state === "shared"
|
||
|
|
? "Sent ✓"
|
||
|
|
: state === "error"
|
||
|
|
? "Try again"
|
||
|
|
: canNativeShare
|
||
|
|
? "Share link"
|
||
|
|
: "Copy link";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex flex-wrap items-center gap-3">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={canNativeShare ? shareNative : copyToClipboard}
|
||
|
|
className={
|
||
|
|
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors " +
|
||
|
|
(state === "error"
|
||
|
|
? "bg-danger text-accent-on"
|
||
|
|
: "bg-accent text-accent-on hover:bg-accent-hover")
|
||
|
|
}
|
||
|
|
aria-live="polite"
|
||
|
|
>
|
||
|
|
<IconShare className="w-4 h-4" />
|
||
|
|
<span>{label}</span>
|
||
|
|
</button>
|
||
|
|
<a
|
||
|
|
href={url}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="text-sm font-semibold text-fg hover:underline"
|
||
|
|
>
|
||
|
|
Open link ↗
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function IconShare({ className }: { className?: string }) {
|
||
|
|
return (
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth={1.8}
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
className={className}
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<circle cx="6" cy="12" r="2.4" />
|
||
|
|
<circle cx="18" cy="6" r="2.4" />
|
||
|
|
<circle cx="18" cy="18" r="2.4" />
|
||
|
|
<path d="M8.1 11l7.8-4M8.1 13l7.8 4" />
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|