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 && (
+ <>
+
+
+ {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",
}