From 28a28ab4682ab6d7c2ffa9f4b0df1c88343273e1 Mon Sep 17 00:00:00 2001 From: "Mike P. Sinn" Date: Sun, 17 May 2026 12:50:32 -0500 Subject: [PATCH 01/22] Add public profile task assignment --- packages/web/e2e/utils/visual-routes.ts | 14 + packages/web/e2e/visual-regression.spec.ts | 4 + packages/web/src/app/people/[id]/page.tsx | 59 +++- packages/web/src/components/Navbar.tsx | 15 ++ .../components/dashboard/PrivacyToggle.tsx | 28 +- .../people/PersonTaskAssignmentAction.tsx | 70 +++++ .../profile/PublicProfileOwnerControls.tsx | 119 ++++++++ .../src/components/site/CampaignActionFab.tsx | 205 +------------- .../src/components/tasks/CreateTaskDialog.tsx | 253 ++++++++++++++++++ .../src/lib/__tests__/tasks.server.test.ts | 54 ++++ packages/web/src/lib/tasks.server.ts | 6 + 11 files changed, 601 insertions(+), 226 deletions(-) create mode 100644 packages/web/src/components/people/PersonTaskAssignmentAction.tsx create mode 100644 packages/web/src/components/profile/PublicProfileOwnerControls.tsx create mode 100644 packages/web/src/components/tasks/CreateTaskDialog.tsx diff --git a/packages/web/e2e/utils/visual-routes.ts b/packages/web/e2e/utils/visual-routes.ts index a074b8271..78d37bc34 100644 --- a/packages/web/e2e/utils/visual-routes.ts +++ b/packages/web/e2e/utils/visual-routes.ts @@ -59,6 +59,20 @@ const SEEDED_DYNAMIC_ROUTES: VisualRoute[] = [ required: false, }, { name: "people-mike", path: "/people/mike", required: false }, + { + name: "people-demo-owner", + path: "/people/demo", + required: false, + authenticated: true, + requiredText: /Public Profile URL/, + }, + { + name: "people-demo-assign-dialog", + path: "/people/demo?assignTask=1", + required: false, + authenticated: true, + requiredText: /Assignee/, + }, { name: "task-optimize-earth", path: "/tasks/optimize-earth", required: false }, { name: "task-one-percent-treaty", path: "/tasks/1-pct-treaty", required: false }, { name: "task-signer-canada", path: "/tasks/1-pct-treaty-signer-ca", required: false }, diff --git a/packages/web/e2e/visual-regression.spec.ts b/packages/web/e2e/visual-regression.spec.ts index f18a19005..e17387cee 100644 --- a/packages/web/e2e/visual-regression.spec.ts +++ b/packages/web/e2e/visual-regression.spec.ts @@ -169,6 +169,10 @@ async function openVisualRoute(page: Page, routePath: string) { waitUntil: "domcontentloaded", timeout: 90_000, }); + await page.waitForLoadState("load", { timeout: 10_000 }).catch(() => { + // Some streaming pages keep subresources open; screenshots only need the + // final document after redirects settle. + }); await forceAnimationsComplete(page); expect(errors, `${routePath} should not throw client-side errors`).toEqual([]); diff --git a/packages/web/src/app/people/[id]/page.tsx b/packages/web/src/app/people/[id]/page.tsx index 58c2718b2..9fcd49b2d 100644 --- a/packages/web/src/app/people/[id]/page.tsx +++ b/packages/web/src/app/people/[id]/page.tsx @@ -10,6 +10,8 @@ import { notFound } from "next/navigation"; import { SufferingPreventedMetric } from "@/components/referendum/SignatoriesLeaderboard"; import { Avatar } from "@/components/retroui/Avatar"; import { CopyLinkButton } from "@/components/sharing/copy-link-button"; +import { PublicProfileOwnerControls } from "@/components/profile/PublicProfileOwnerControls"; +import { PersonTaskAssignmentAction } from "@/components/people/PersonTaskAssignmentAction"; import { defaultButtonClassName, primaryButtonClassName, @@ -25,8 +27,10 @@ import { buildOfficialReferendumVoteWhere } from "@/lib/referendum-vote-classifi import { humanityVGovernmentLink, plaintiffsLink, + getSignInPath, ROUTES, } from "@/lib/routes"; +import { getPersonHref } from "@/lib/person-href"; import { getRepresentedPersonProfileData, type RepresentedPersonProfileData, @@ -484,6 +488,11 @@ export default async function PersonDetailPage({ }); const visitorStatus = await getVisitorTreatyStatus(userId); const { openTasks, person, verifiedTasks } = data; + const isOwnProfile = Boolean(userId && person.user?.id === userId); + const publicProfileHref = getPersonHref(person); + const publicProfileUrl = `${requestOrigin}${publicProfileHref}`; + const assignTaskCallbackHref = `${publicProfileHref}?assignTask=1`; + const assignTaskSignInHref = getSignInPath(assignTaskCallbackHref); const fallbackInitials = getFallbackInitials(person.displayName); const treatyVote = getVoteBySlug(person, TREATY_REFERENDUM_SLUG); const courtVote = getVoteBySlug(person, DECLARATION_SLUG); @@ -530,6 +539,14 @@ export default async function PersonDetailPage({ return (
+ {isOwnProfile ? ( + + ) : null} +
{person.image ? ( @@ -563,12 +580,21 @@ export default async function PersonDetailPage({
{shouldShowVisitorReferral && visitorReferralUrl ? (
- - Forward My Referral - +

Your referral URL @@ -587,12 +613,21 @@ export default async function PersonDetailPage({

) : ( - - Sign the Treaty - + )}
diff --git a/packages/web/src/components/Navbar.tsx b/packages/web/src/components/Navbar.tsx index c0edd652c..2e4732d20 100644 --- a/packages/web/src/components/Navbar.tsx +++ b/packages/web/src/components/Navbar.tsx @@ -77,6 +77,9 @@ export default function Navbar({ config = defaultNavConfig }: NavbarProps) { const [navQuery, setNavQuery] = useState(""); const isAuthenticated = status === "authenticated"; const user = session?.user ?? null; + const publicProfileHref = user?.personId + ? `${ROUTES.people}/${user.handle ?? user.personId}` + : null; const quickAction = config.quickAction ?? null; const quickActionHref = quickAction ? isAuthenticated @@ -345,6 +348,18 @@ export default function Navbar({ config = defaultNavConfig }: NavbarProps) {
{isAuthenticated ? ( <> + {publicProfileHref ? ( + + + {profileLink.cta} + + + ) : null} void + disabled?: boolean } -export function PrivacyToggle({ isPublic, onChange }: PrivacyToggleProps) { +export function PrivacyToggle({ + disabled = false, + isPublic, + onChange, +}: PrivacyToggleProps) { return (
-
onChange(!isPublic)} + type="button" > - {/* Sliding Background */} - {/* Private Option (Left) */}
- πŸ”’ PRIVATE
- {/* Public Option (Right) */}
- 🌍 - + PUBLIC
-
+ - {/* Description Text */}
{isPublic ? ( { + if ( + !handledAutoOpen.current && + isAuthenticated && + searchParams.get("assignTask") === "1" + ) { + handledAutoOpen.current = true; + setOpen(true); + } + }, [isAuthenticated, searchParams]); + + if (!isAuthenticated) { + return ( + + + Assign Task + + ); + } + + return ( + <> + + + + ); +} diff --git a/packages/web/src/components/profile/PublicProfileOwnerControls.tsx b/packages/web/src/components/profile/PublicProfileOwnerControls.tsx new file mode 100644 index 000000000..266faf947 --- /dev/null +++ b/packages/web/src/components/profile/PublicProfileOwnerControls.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { CopyLinkButton } from "@/components/sharing/copy-link-button"; +import { PrivacyToggle } from "@/components/dashboard/PrivacyToggle"; +import { defaultButtonClassName } from "@/components/ui/default-button"; +import { ROUTES } from "@/lib/routes"; + +interface PublicProfileOwnerControlsProps { + initialIsPublic: boolean; + publicProfileHref: string; + publicProfileUrl: string; +} + +export function PublicProfileOwnerControls({ + initialIsPublic, + publicProfileHref, + publicProfileUrl, +}: PublicProfileOwnerControlsProps) { + const router = useRouter(); + const [isPublic, setIsPublic] = useState(initialIsPublic); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isPending, startTransition] = useTransition(); + const isBusy = isSaving || isPending; + + async function updateVisibility(nextIsPublic: boolean) { + setError(null); + setIsSaving(true); + + let response: Response; + try { + response = await fetch("/api/dashboard/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isPublic: nextIsPublic }), + }); + } catch { + setIsSaving(false); + setError("Profile visibility did not update. Try again."); + return; + } + + if (!response.ok) { + setIsSaving(false); + setError("Profile visibility did not update. Try again."); + return; + } + + setIsPublic(nextIsPublic); + setIsSaving(false); + startTransition(() => { + router.refresh(); + }); + } + + return ( +
+
+
+

+ Your public to-do list +

+

+ This is how other humans help you figure out the most valuable action you can take to maximize humanity's median income and healthy life expectancy. Make it public to get help from your network and the world, or keep it private if you prefer. You can change this anytime. +

+ +
+

+ Public Profile URL +

+
+

+ {publicProfileUrl} +

+ +
+
+ +
+ + View Profile + + + Edit Profile + +
+
+ +
+

+ Privacy Settings +

+ void updateVisibility(value)} + /> + {error ? ( +

{error}

+ ) : null} +
+
+
+ ); +} diff --git a/packages/web/src/components/site/CampaignActionFab.tsx b/packages/web/src/components/site/CampaignActionFab.tsx index bb7640d15..f8fafac82 100644 --- a/packages/web/src/components/site/CampaignActionFab.tsx +++ b/packages/web/src/components/site/CampaignActionFab.tsx @@ -1,17 +1,12 @@ "use client"; -import { FormEvent, useMemo, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { usePathname } from "next/navigation"; import { useSession } from "next-auth/react"; import { Check, Clipboard, Plus, Share2, X } from "lucide-react"; import { Button } from "@/components/retroui/Button"; -import { Dialog } from "@/components/retroui/Dialog"; -import { Input } from "@/components/retroui/Input"; -import { Textarea } from "@/components/retroui/Textarea"; +import { CreateTaskDialog } from "@/components/tasks/CreateTaskDialog"; import { buildUserReferralUrl } from "@/lib/url"; -import { cn } from "@/lib/utils"; - -type TaskMode = "self" | "person"; const HIDDEN_PATH_PREFIXES = [ "/api", @@ -43,24 +38,12 @@ async function copyToClipboard(text: string) { document.body.removeChild(textarea); } -async function readApiError(response: Response) { - const body = await response.json().catch(() => null); - return typeof body?.error === "string" ? body.error : "Request failed."; -} - export function CampaignActionFab() { const pathname = usePathname(); - const router = useRouter(); const { data: session, status } = useSession(); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const [taskOpen, setTaskOpen] = useState(false); - const [taskMode, setTaskMode] = useState("self"); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [assignee, setAssignee] = useState(""); - const [creating, setCreating] = useState(false); - const [error, setError] = useState(null); const referralUrl = useMemo( () => buildUserReferralUrl(session?.user), @@ -77,70 +60,10 @@ export function CampaignActionFab() { window.setTimeout(() => setCopied(false), 2000); } - function resetTaskForm() { - setTaskMode("self"); - setTitle(""); - setDescription(""); - setAssignee(""); - setError(null); - } - - async function createTask(event: FormEvent) { - event.preventDefault(); - const trimmedTitle = title.trim(); - if (!trimmedTitle) { - setError("Title is required."); - return; - } - if (taskMode === "person" && !assignee.trim()) { - setError("Person handle or URL is required."); - return; - } - - try { - setCreating(true); - setError(null); - const response = await fetch("/api/tasks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - assigneePersonId: - taskMode === "self" ? session?.user.personId : undefined, - assigneePersonIdentifier: - taskMode === "person" ? assignee.trim() : undefined, - description: description.trim(), - isPublic: taskMode === "person", - title: trimmedTitle, - }), - }); - - if (!response.ok) { - throw new Error(await readApiError(response)); - } - - const body = (await response.json()) as { data?: { id?: string } }; - const taskId = body.data?.id; - setTaskOpen(false); - resetTaskForm(); - if (taskId) { - router.push(`/tasks/${taskId}`); - } else { - router.push("/tasks"); - } - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create task."); - } finally { - setCreating(false); - } - } - const actionButtonClass = "group min-h-10 justify-start gap-2 rounded-full bg-background/95 py-1.5 pl-1.5 pr-3 text-xs font-black uppercase tracking-[0.08em] text-foreground shadow-[0_8px_24px_rgba(0,0,0,0.14)] ring-1 ring-foreground/10 hover:bg-foreground hover:text-background focus-visible:ring-2 focus-visible:ring-foreground"; const actionIconClass = "flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-foreground text-background group-hover:bg-background group-hover:text-foreground"; - const fieldClass = - "border border-foreground bg-background font-bold shadow-none focus:shadow-none"; return ( <> @@ -198,127 +121,7 @@ export function CampaignActionFab() {
) : null} - { - setTaskOpen(nextOpen); - if (!nextOpen) resetTaskForm(); - }} - > - -
void createTask(event)}> - -
-

- Create task -

- - - Close - -
-
- -
-
- {(["self", "person"] as const).map((mode) => ( - - ))} -
- - - -