diff --git a/src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx b/src/app/(private)/map/[id]/components/PrivateMapNavbar.tsx index aaaabf07..81159aac 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 00000000..8330efe5 --- /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 a320bff2..5efcf57b 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 2944c9d1..4a9e8427 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", }