Files
buzz/web/src/features/invite/ui/InvitePage.tsx
Tyler 6c2d667575 Apply optional relay join policy across join flows (#1894)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: klopez4212 <klopez4212@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
2026-07-16 13:59:53 -04:00

208 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import buzzAppIcon from "@/assets/app-icon@3x.png";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
import * as React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest";
type JoinPolicy = {
terms_markdown?: string;
privacy_markdown?: string;
age_attestation_required: boolean;
version: string;
};
type PolicyDocument = { title: string; markdown: string };
/** Landing page for a community invite link (`/invite/<code>`). */
export function InvitePage({ code }: { code: string }) {
const relay = relayWsUrl();
const host = relay.replace(/^wss?:\/\//, "");
const [policy, setPolicy] = React.useState<JoinPolicy | null | undefined>(
undefined,
);
const [document, setDocument] = React.useState<PolicyDocument | null>(null);
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
const [opening, setOpening] = React.useState(false);
React.useEffect(() => {
fetch("/api/join-policy")
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const config = (await response.json()) as { policy?: JoinPolicy };
setPolicy(config.policy ?? null);
})
.catch(() => setPolicy(undefined));
}, []);
const openInvite = async () => {
setOpening(true);
try {
let receipt: string | undefined;
if (policy) {
const response = await fetch("/api/invites/accept-policy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
policy_version: policy.version,
age_confirmed: ageConfirmed,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
receipt = ((await response.json()) as { receipt: string }).receipt;
}
const query = new URLSearchParams({ relay, code });
if (receipt) query.set("policy_receipt", receipt);
window.location.href = `buzz://join?${query.toString()}`;
} finally {
setOpening(false);
}
};
const disabled =
policy === undefined ||
opening ||
Boolean(policy?.age_attestation_required && !ageConfirmed);
const showDocument = (title: string, markdown: string) =>
setDocument({ title, markdown });
return (
<div
className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
style={{
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
}}
>
<div className="w-full max-w-xl space-y-4">
<div className="flex w-full flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12">
<div
className="h-12 w-12 overflow-hidden bg-black"
style={{ borderRadius: "22.37%" }}
>
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
</div>
<h1 className="mt-4 text-2xl font-semibold tracking-tight text-black">
You&apos;re invited to
</h1>
<p className="mt-9 font-mono text-lg text-black/70">{host}</p>
{policy?.age_attestation_required && (
<label className="mt-9 flex max-w-md cursor-pointer items-start gap-3 text-left text-sm text-black/70">
<input
className="mt-0.5 h-4 w-4 accent-black"
type="checkbox"
checked={ageConfirmed}
onChange={(event) => setAgeConfirmed(event.target.checked)}
/>
<span>I am 18 years of age or older.</span>
</label>
)}
<div className={policy?.age_attestation_required ? "mt-5" : "mt-9"}>
{policy === null ? (
<Button
asChild
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
size="lg"
>
<a
href={`buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`}
>
Accept invite in Buzz
</a>
</Button>
) : (
<Button
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black disabled:cursor-not-allowed disabled:bg-black/30 disabled:text-white/70"
size="lg"
disabled={disabled}
onClick={openInvite}
>
Accept invite in Buzz
</Button>
)}
</div>
{policy && (policy.terms_markdown || policy.privacy_markdown) && (
<p className="mt-4 max-w-md text-xs text-black/60">
By proceeding you agree to the Buzz{" "}
{policy.terms_markdown && (
<button
className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
type="button"
onClick={() =>
showDocument(
"Terms of Service",
policy.terms_markdown ?? "",
)
}
>
Terms of Service
</button>
)}
{policy.terms_markdown && policy.privacy_markdown && " and "}
{policy.privacy_markdown && (
<button
className="text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
type="button"
onClick={() =>
showDocument(
"Privacy Policy",
policy.privacy_markdown ?? "",
)
}
>
Privacy Policy
</button>
)}
.
</p>
)}
</div>
<p className="flex h-[3.125rem] items-center justify-center rounded-2xl bg-white text-sm text-black/60">
Don&apos;t have the app?{" "}
<a
className="ml-1 font-medium text-black underline-offset-4 hover:text-black/70 hover:underline focus-visible:underline"
href={DOWNLOAD_URL}
rel="noreferrer"
target="_blank"
>
Download it now
</a>
</p>
</div>
{document && (
<div
aria-label={document.title}
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 text-left"
role="dialog"
onMouseDown={(event) => {
if (event.currentTarget === event.target) setDocument(null);
}}
>
<div className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-2xl bg-white p-6 text-black shadow-xl sm:p-8">
<div className="mb-6 flex items-start justify-between gap-4">
<h2 className="text-xl font-semibold">{document.title}</h2>
<button
aria-label="Close"
className="text-2xl leading-none text-black/60 hover:text-black"
type="button"
onClick={() => setDocument(null)}
>
×
</button>
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>
{document.markdown}
</Markdown>
</div>
</div>
</div>
)}
</div>
);
}