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": "من",
|
"startsAt": "من",
|
||||||
"endsAt": "إلى",
|
"endsAt": "إلى",
|
||||||
"viewList": "قائمة",
|
"viewList": "قائمة",
|
||||||
"viewCalendar": "تقويم"
|
"viewCalendar": "تقويم",
|
||||||
|
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
|
||||||
|
"copyLink": "نسخ الرابط",
|
||||||
|
"linkCopied": "تم نسخ الرابط",
|
||||||
|
"linkCopyFailed": "تعذّر نسخ الرابط"
|
||||||
},
|
},
|
||||||
"external": {
|
"external": {
|
||||||
"new": "لقاء جديد",
|
"new": "لقاء جديد",
|
||||||
|
|||||||
@@ -1594,7 +1594,11 @@
|
|||||||
"startsAt": "From",
|
"startsAt": "From",
|
||||||
"endsAt": "To",
|
"endsAt": "To",
|
||||||
"viewList": "List",
|
"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": {
|
"external": {
|
||||||
"new": "New meeting",
|
"new": "New meeting",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useRoute } from "wouter";
|
import { useLocation, useRoute } from "wouter";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
PackageCheck,
|
PackageCheck,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
List as ListIcon,
|
List as ListIcon,
|
||||||
|
Link2,
|
||||||
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -361,6 +363,43 @@ export default function ProtocolPage() {
|
|||||||
id: number;
|
id: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
|
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<
|
const [deleteTarget, setDeleteTarget] = useState<
|
||||||
| { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number }
|
| { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number }
|
||||||
| null
|
| null
|
||||||
@@ -667,6 +706,40 @@ export default function ProtocolPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{bookings.data?.length === 0 && (
|
||||||
<EmptyState text={t("protocol.bookings.empty")} />
|
<EmptyState text={t("protocol.bookings.empty")} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user