From 126d63c58a0cf136f4bfbb77e7cc113e6994b375 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Wed, 5 Aug 2026 20:51:31 +0200 Subject: [PATCH] feat: share dialog, SharedMaps feature flag, publish toggle rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 6 (final) of the read-only private maps feature (plan in READ_ONLY_PRIVATE_MAPS.md): - ShareMapDialog: Share button + popover in the private map navbar — enable/disable the read-only link, set/change/remove the optional password, copy the link, and reset it (invalidating the old URL) - New Feature.SharedMaps organisation flag gating the Share button (dev mode enables all flags, as with the existing ones) - MapModeToggle "Share" label renamed to "Publish", freeing "share" for the new read-only link feature; both navbars use the same component so one rename covers the editor and publish modes Co-Authored-By: Claude Fable 5 --- .../map/[id]/components/PrivateMapNavbar.tsx | 7 + .../map/[id]/components/ShareMapDialog.tsx | 252 ++++++++++++++++++ src/components/MapModeToggle.tsx | 2 +- src/models/Organisation.ts | 1 + 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/app/(private)/map/[id]/components/ShareMapDialog.tsx diff --git a/src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx b/src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx index aaaabf076..81159aac3 100644 --- a/src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx +++ b/src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx @@ -26,6 +26,7 @@ import { useMapId, useMapRef } from "../hooks/useMapCore"; import { useMapViews } from "../hooks/useMapViews"; import MapViews from "./MapViews"; import PrivateMapNavbarControls from "./PrivateMapNavbarControls"; +import ShareMapDialog from "./ShareMapDialog"; export default function PrivateMapNavbar() { const mapId = useMapId(); @@ -39,6 +40,10 @@ export default function PrivateMapNavbar() { Feature.PublicMaps, currentOrganisation?.features, ); + const showShareButton = useFeatureFlagEnabled( + Feature.SharedMaps, + currentOrganisation?.features, + ); const [isEditingName, setIsEditingName] = useState(false); const [editedName, setEditedName] = useState(map?.name || ""); @@ -220,6 +225,8 @@ export default function PrivateMapNavbar() {
+ {showShareButton && mapId && } + {showPublishButton && mapId && view && ( )} diff --git a/src/app/(private)/map/[id]/components/ShareMapDialog.tsx b/src/app/(private)/map/[id]/components/ShareMapDialog.tsx new file mode 100644 index 000000000..8330efe58 --- /dev/null +++ b/src/app/(private)/map/[id]/components/ShareMapDialog.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CopyIcon, Share2Icon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTRPC } from "@/services/trpc/react"; +import { Button } from "@/shadcn/ui/button"; +import { Input } from "@/shadcn/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; +import { Switch } from "@/shadcn/ui/switch"; +import type { RouterOutputs } from "@/services/trpc/react"; + +type ShareState = RouterOutputs["mapShare"]["get"]; + +// Matches the server's passwordSchema +const MIN_PASSWORD_LENGTH = 8; + +/** + * The Share button + popover in the private map navbar: enable/disable + * the read-only link, set an optional password, copy the link, and reset + * it. Distinct from Publish mode, which builds a public campaign site — + * this shares the live private map view with a small audience. + */ +export default function ShareMapDialog({ mapId }: { mapId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: share, isPending: shareLoading } = useQuery( + trpc.mapShare.get.queryOptions({ mapId }), + ); + + const [passwordEditing, setPasswordEditing] = useState(false); + const [password, setPassword] = useState(""); + const [passwordError, setPasswordError] = useState(null); + + const updateShareCache = (data: ShareState) => + queryClient.setQueryData(trpc.mapShare.get.queryKey({ mapId }), data); + + const { mutate: enableShare, isPending: enabling } = useMutation( + trpc.mapShare.enable.mutationOptions({ + onSuccess: (data) => updateShareCache(data), + onError: () => toast.error("Failed to enable link sharing"), + }), + ); + const { mutate: disableShare, isPending: disabling } = useMutation( + trpc.mapShare.disable.mutationOptions({ + onSuccess: (data) => updateShareCache(data), + onError: () => toast.error("Failed to disable link sharing"), + }), + ); + const { mutate: setSharePassword, isPending: settingPassword } = useMutation( + trpc.mapShare.setPassword.mutationOptions({ + onSuccess: (data, variables) => { + updateShareCache(data); + setPasswordEditing(false); + setPassword(""); + toast.success(variables.password ? "Password set" : "Password removed"); + }, + onError: () => toast.error("Failed to update the password"), + }), + ); + const { mutate: regenerateToken, isPending: regenerating } = useMutation( + trpc.mapShare.regenerateToken.mutationOptions({ + onSuccess: (data) => { + updateShareCache(data); + toast.success("Link reset. The old link no longer works."); + }, + onError: () => toast.error("Failed to reset the link"), + }), + ); + + const mutating = enabling || disabling || settingPassword || regenerating; + const enabled = Boolean(share?.enabled); + const hasPassword = Boolean(share?.hasPassword); + const shareUrl = + share && typeof window !== "undefined" + ? `${window.location.origin}/share/${share.token}` + : ""; + + const onToggleLink = (checked: boolean) => { + if (checked) { + enableShare({ mapId }); + } else { + disableShare({ mapId }); + } + }; + + const onTogglePassword = (checked: boolean) => { + setPasswordError(null); + setPassword(""); + if (checked) { + setPasswordEditing(true); + return; + } + setPasswordEditing(false); + if (hasPassword) { + setSharePassword({ mapId, password: null }); + } + }; + + const onSavePassword = () => { + const trimmed = password.trim(); + if (trimmed.length < MIN_PASSWORD_LENGTH) { + setPasswordError( + `Password must be at least ${MIN_PASSWORD_LENGTH} characters`, + ); + return; + } + setPasswordError(null); + setSharePassword({ mapId, password: trimmed }); + }; + + const onCopyLink = async () => { + if (!shareUrl) { + return; + } + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied"); + } catch { + toast.error("Failed to copy the link"); + } + }; + + return ( + + + + + +

Share this map

+ +
+
+

Read-only link

+

+ Anyone with the link can view the live map, but not edit it. +

+
+ +
+ + {enabled && share && ( + <> +
+
+

Require a password

+ +
+ {passwordEditing && ( + <> +
+ setPassword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onSavePassword(); + } + }} + /> + +
+ {passwordError && ( +

{passwordError}

+ )} + + )} + {!passwordEditing && hasPassword && ( +
+

+ Viewers must enter this password. Changing or removing it + signs current viewers out. +

+ +
+ )} +
+ +
+
+ e.currentTarget.select()} + /> + +
+ +

+ Resetting creates a new link; the old one stops working. +

+
+ + )} +
+
+ ); +} diff --git a/src/components/MapModeToggle.tsx b/src/components/MapModeToggle.tsx index a320bff26..5efcf57ba 100644 --- a/src/components/MapModeToggle.tsx +++ b/src/components/MapModeToggle.tsx @@ -63,7 +63,7 @@ export default function MapModeToggle({ mode }: MapModeToggleProps) { : "text-neutral-500 hover:text-neutral-700", )} > - Share + Publish
); diff --git a/src/models/Organisation.ts b/src/models/Organisation.ts index 2944c9d19..4a9e8427d 100644 --- a/src/models/Organisation.ts +++ b/src/models/Organisation.ts @@ -4,6 +4,7 @@ export enum Feature { PublicMaps = "PublicMaps", Enrichment = "Enrichment", InviteUsers = "InviteUsers", + SharedMaps = "SharedMaps", SyncToCrm = "SyncToCrm", }