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:
@@ -1721,7 +1721,11 @@
|
||||
"startsAt": "من",
|
||||
"endsAt": "إلى",
|
||||
"viewList": "قائمة",
|
||||
"viewCalendar": "تقويم"
|
||||
"viewCalendar": "تقويم",
|
||||
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"linkCopied": "تم نسخ الرابط",
|
||||
"linkCopyFailed": "تعذّر نسخ الرابط"
|
||||
},
|
||||
"external": {
|
||||
"new": "لقاء جديد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user