feat(protocol): shareable public booking link bar in Room Bookings

Task #655: staff need a one-click way to copy and share the public
(no-login) room-booking link with guests.

- Add a link bar at the top of the "حجوزات القاعات" (Room Bookings)
  section showing the absolute public URL, with a "نسخ الرابط" copy
  button that gives toast + a temporary "تم النسخ" confirmation state.
- URL is built from window.location.origin + BASE_URL (trailing slash
  stripped) + "/protocol/request", so it works on root and subpath
  deploys and when pasted into a browser/WhatsApp.
- Clipboard uses navigator.clipboard with a textarea+execCommand
  fallback; fallback now checks execCommand's return and reports failure
  (destructive toast) instead of falsely showing success. Textarea is
  removed in a finally block. Copied-state timer stored in a ref and
  cleared on repeat clicks to avoid a reset race.
- URL text forced dir="ltr" inside the RTL layout; new i18n keys
  (shareLinkTitle, copyLink, linkCopied, linkCopyFailed) added under
  protocol.bookings in both ar.json and en.json.

Verified: tsc typecheck passes; /protocol serves 200; module transforms
cleanly via Vite; architect review issues (fallback false-success,
timer race) fixed.
This commit is contained in:
Replit Agent
2026-07-06 12:20:17 +00:00
parent 323472eec0
commit 17950daa5e
3 changed files with 84 additions and 3 deletions
+5 -1
View File
@@ -1721,7 +1721,11 @@
"startsAt": "من",
"endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم"
"viewCalendar": "تقويم",
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
"linkCopyFailed": "تعذّر نسخ الرابط"
},
"external": {
"new": "لقاء جديد",
+5 -1
View File
@@ -1594,7 +1594,11 @@
"startsAt": "From",
"endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar"
"viewCalendar": "Calendar",
"shareLinkTitle": "Public booking link (share with guests)",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"linkCopyFailed": "Could not copy the link"
},
"external": {
"new": "New meeting",
+74 -1
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useRoute } from "wouter";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -20,6 +20,8 @@ import {
PackageCheck,
CalendarDays,
List as ListIcon,
Link2,
Copy,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -361,6 +363,43 @@ export default function ProtocolPage() {
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const publicBookingUrl = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/protocol/request`;
const [linkCopied, setLinkCopied] = useState(false);
const linkCopiedTimer = useRef<number | null>(null);
const copyBookingLink = async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(publicBookingUrl);
} else {
const ta = document.createElement("textarea");
ta.value = publicBookingUrl;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
document.body.removeChild(ta);
}
if (!ok) throw new Error("execCommand copy failed");
}
setLinkCopied(true);
toast({ title: t("protocol.bookings.linkCopied") });
if (linkCopiedTimer.current) window.clearTimeout(linkCopiedTimer.current);
linkCopiedTimer.current = window.setTimeout(
() => setLinkCopied(false),
2000,
);
} catch {
toast({
title: t("protocol.bookings.linkCopyFailed"),
variant: "destructive",
});
}
};
const [deleteTarget, setDeleteTarget] = useState<
| { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number }
| null
@@ -667,6 +706,40 @@ export default function ProtocolPage() {
)}
</div>
</div>
<div className="rounded-lg border border-sky-200 bg-sky-50/60 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-2">
<Link2 size={16} className="shrink-0 text-sky-600" />
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700">
{t("protocol.bookings.shareLinkTitle")}
</p>
<p
dir="ltr"
className="truncate text-xs text-slate-500 text-start"
title={publicBookingUrl}
>
{publicBookingUrl}
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={copyBookingLink}
className="shrink-0 self-start sm:self-auto"
>
{linkCopied ? (
<Check size={16} className="me-1 text-emerald-600" />
) : (
<Copy size={16} className="me-1" />
)}
{linkCopied
? t("protocol.bookings.linkCopied")
: t("protocol.bookings.copyLink")}
</Button>
</div>
</div>
{bookings.data?.length === 0 && (
<EmptyState text={t("protocol.bookings.empty")} />
)}