first commit
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSdk, useChannel } from "@lynkd/messaging-inbox-sdk";
|
||||
import { useUi } from "@/lib/ui-state";
|
||||
import { Modal, Field, inputCls, PrimaryBtn, GhostBtn } from "@/components/ui/Modal";
|
||||
import { Avatar } from "@/components/ui/primitives";
|
||||
import { EmojiPicker } from "@/components/message/EmojiPicker";
|
||||
import { Hash, Lock, Smile, Upload, Trash2 } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const AVATAR_COLORS = ["#FDA913", "#5b9dff", "#3ecf8e", "#bb98ff", "#f2555a", "#ff7a3d", "#54e7ff", "#f5b544", "#8c8c94"];
|
||||
|
||||
export function Modals() {
|
||||
const { modal } = useUi();
|
||||
if (!modal) return null;
|
||||
return (
|
||||
<>
|
||||
{modal === "create-channel" && <CreateChannelModal />}
|
||||
{modal === "new-message" && <NewMessageModal />}
|
||||
{modal === "invite" && <InviteModal />}
|
||||
{modal === "create-workspace" && <CreateWorkspaceModal />}
|
||||
{modal === "callback" && <CallbackModal />}
|
||||
{modal === "channel-details" && <ChannelDetailsModal />}
|
||||
{modal === "channel-members" && <ChannelMembersModal />}
|
||||
{modal === "set-status" && <StatusModal />}
|
||||
{modal === "edit-profile" && <EditProfileModal />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelMembersModal() {
|
||||
const { modalArg, closeModal, toast } = useUi();
|
||||
const { channelById, users, client, refresh, me } = useSdk();
|
||||
const channel = useChannel(modalArg ?? null);
|
||||
const [q, setQ] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!channel) return <Modal title="Members" onClose={closeModal}><p className="text-[13px] text-muted">No channel selected.</p></Modal>;
|
||||
|
||||
const memberIds = channelById(channel.id)?.memberIds ?? channel.memberIds;
|
||||
const members = memberIds.map((id) => users.find((u) => u.id === id)).filter(Boolean) as typeof users;
|
||||
const candidates = users.filter((u) => !memberIds.includes(u.id) && (u.name.toLowerCase().includes(q.toLowerCase()) || !q));
|
||||
|
||||
const add = async (userId: string, name: string) => { setBusy(true); await client.addMember(channel.id, userId); refresh(); setBusy(false); toast(`Added ${name} to #${channel.name}`, "success"); };
|
||||
const remove = async (userId: string, name: string) => { setBusy(true); await client.removeMember(channel.id, userId); refresh(); setBusy(false); toast(`Removed ${name}`, "default"); };
|
||||
|
||||
return (
|
||||
<Modal title={`Members of ${channel.kind === "channel" ? "#" + channel.name : channel.name}`} subtitle={`${members.length} member${members.length === 1 ? "" : "s"}`} onClose={closeModal} wide>
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 text-[12px] font-bold uppercase text-dim">In this channel</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{members.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 rounded-control px-2 py-1.5 hover:bg-hover">
|
||||
<Avatar user={u} size={30} />
|
||||
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}{u.id === me?.id ? " (you)" : ""}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
|
||||
{u.id !== me?.id && <button disabled={busy} onClick={() => remove(u.id, u.name)} className="rounded-control px-2 py-1 text-[12px] font-semibold text-danger hover:bg-hover disabled:opacity-40">Remove</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-bold uppercase text-dim">Add people</div>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people to add…" className={inputCls} />
|
||||
<div className="mt-2 max-h-52 space-y-1 overflow-y-auto">
|
||||
{candidates.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 rounded-control px-2 py-1.5 hover:bg-hover">
|
||||
<Avatar user={u} size={30} />
|
||||
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
|
||||
<button disabled={busy} onClick={() => add(u.id, u.name)} className="rounded-control border border-border px-2.5 py-1 text-[12px] font-semibold hover:bg-hover disabled:opacity-40">Add</button>
|
||||
</div>
|
||||
))}
|
||||
{candidates.length === 0 && <div className="py-4 text-center text-[12.5px] text-muted">Everyone's already here.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const CLEAR_OPTIONS: { label: string; ms: number | null }[] = [
|
||||
{ label: "Don't clear", ms: null },
|
||||
{ label: "30 minutes", ms: 30 * 60_000 },
|
||||
{ label: "1 hour", ms: 60 * 60_000 },
|
||||
{ label: "4 hours", ms: 4 * 60 * 60_000 },
|
||||
{ label: "Today", ms: -1 }, // end of today
|
||||
];
|
||||
|
||||
function StatusModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const { me, client, refresh } = useSdk();
|
||||
const [emoji, setEmoji] = useState(me?.statusEmoji ?? "");
|
||||
const [text, setText] = useState(me?.statusText ?? "");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [clearIdx, setClearIdx] = useState(0);
|
||||
const [customAt, setCustomAt] = useState("");
|
||||
const presets = [
|
||||
{ e: "🏗️", t: "On site" }, { e: "📅", t: "In a meeting" }, { e: "🎧", t: "Focusing" },
|
||||
{ e: "🌴", t: "On vacation" }, { e: "🤒", t: "Out sick" }, { e: "🏠", t: "Working remotely" },
|
||||
{ e: "🍽️", t: "Out for lunch" }, { e: "🚗", t: "Commuting" },
|
||||
];
|
||||
const computeClearAt = (): number | undefined => {
|
||||
if (customAt) { const t = Date.parse(customAt); return Number.isNaN(t) ? undefined : t; }
|
||||
const opt = CLEAR_OPTIONS[clearIdx];
|
||||
if (opt.ms === null) return undefined;
|
||||
if (opt.ms === -1) { const d = new Date(); d.setHours(23, 59, 0, 0); return d.getTime(); }
|
||||
return Date.now() + opt.ms;
|
||||
};
|
||||
const save = async () => { await client.setStatus(text.trim() || undefined, emoji || undefined, computeClearAt()); refresh(); closeModal(); toast("Status updated", "success"); };
|
||||
const clear = async () => { await client.setStatus(undefined, undefined); refresh(); closeModal(); toast("Status cleared", "default"); };
|
||||
return (
|
||||
<Modal title="Set a status" onClose={closeModal} footer={<><GhostBtn onClick={clear}>Clear</GhostBtn><PrimaryBtn onClick={save}>Save</PrimaryBtn></>}>
|
||||
<div className="relative mb-3 flex items-center gap-2 rounded-control border border-border bg-panel pl-1.5 pr-3">
|
||||
<button onClick={() => setPickerOpen((o) => !o)} className="grid h-9 w-9 place-items-center rounded-control text-lg hover:bg-hover" title="Pick an emoji">
|
||||
{emoji || <Smile size={18} className="text-muted" />}
|
||||
</button>
|
||||
{pickerOpen && <EmojiPicker onPick={(e) => { setEmoji(e); setPickerOpen(false); }} onClose={() => setPickerOpen(false)} />}
|
||||
<input autoFocus value={text} onChange={(e) => setText(e.target.value)} placeholder="What's your status?" className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-dim" onKeyDown={(e) => e.key === "Enter" && save()} />
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-2 gap-2">
|
||||
{presets.map((p) => (
|
||||
<button key={p.t} onClick={() => { setEmoji(p.e); setText(p.t); }} className="flex items-center gap-2 rounded-control border border-border px-3 py-2 text-left text-[13px] hover:bg-hover">
|
||||
<span className="text-base">{p.e}</span> {p.t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Field label="Clear after">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CLEAR_OPTIONS.map((o, i) => (
|
||||
<button key={o.label} onClick={() => { setClearIdx(i); setCustomAt(""); }} className={clsx("rounded-pill border px-2.5 py-1 text-[12px] font-medium", !customAt && clearIdx === i ? "border-accent text-accent" : "border-border text-muted hover:text-ink")}>{o.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<input type="datetime-local" value={customAt} onChange={(e) => setCustomAt(e.target.value)} className={clsx(inputCls, "mt-2")} title="Custom clear time" />
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditProfileModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const { me, client, refresh } = useSdk();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState(me?.name ?? "");
|
||||
const [title, setTitle] = useState(me?.title ?? "");
|
||||
const [color, setColor] = useState(me?.avatarColor ?? AVATAR_COLORS[0]);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(me?.avatarUrl ?? null);
|
||||
|
||||
const onFile = (f: File | undefined) => {
|
||||
if (!f) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatarUrl(String(reader.result));
|
||||
reader.readAsDataURL(f);
|
||||
};
|
||||
const save = async () => {
|
||||
await client.updateMe({ name: name.trim() || undefined, title: title.trim() || undefined, avatarColor: color, avatarUrl: avatarUrl });
|
||||
refresh();
|
||||
closeModal();
|
||||
toast("Profile updated", "success");
|
||||
};
|
||||
|
||||
const preview = { avatarText: (name || "?").slice(0, 2).toUpperCase(), avatarColor: color, presence: (me?.presence ?? "active") as any, name, avatarUrl: avatarUrl ?? undefined };
|
||||
|
||||
return (
|
||||
<Modal title="Edit profile" onClose={closeModal} footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={save}>Save changes</PrimaryBtn></>}>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<Avatar user={preview} size={72} showPresence={false} rounded="20px" />
|
||||
<div className="flex flex-col gap-2">
|
||||
<button onClick={() => fileRef.current?.click()} className="focus-ring flex items-center gap-2 rounded-control border border-border px-3 py-1.5 text-[13px] font-semibold hover:bg-hover"><Upload size={14} /> Upload image</button>
|
||||
{avatarUrl && <button onClick={() => setAvatarUrl(null)} className="flex items-center gap-1.5 text-[12px] text-danger hover:underline"><Trash2 size={12} /> Remove image</button>}
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => { onFile(e.target.files?.[0]); e.target.value = ""; }} />
|
||||
</div>
|
||||
</div>
|
||||
{!avatarUrl && (
|
||||
<Field label="Avatar color">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AVATAR_COLORS.map((c) => (
|
||||
<button key={c} onClick={() => setColor(c)} className="h-8 w-8 rounded-full ring-2 ring-transparent transition-all hover:scale-110" style={{ background: c, boxShadow: color === c ? "0 0 0 2px var(--c-bg), 0 0 0 4px " + c : undefined }} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Display name"><input value={name} onChange={(e) => setName(e.target.value)} className={inputCls} /></Field>
|
||||
<Field label="Title"><input value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateChannelModal() {
|
||||
const { client, refresh, users, me } = useSdk();
|
||||
const { closeModal, toast } = useUi();
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [priv, setPriv] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const others = users.filter((u) => u.id !== me?.id);
|
||||
const candidates = others.filter((u) => u.name.toLowerCase().includes(q.toLowerCase()) || u.handle.toLowerCase().includes(q.toLowerCase()));
|
||||
const toggle = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
|
||||
async function create() {
|
||||
const clean = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
||||
if (!clean) return;
|
||||
setBusy(true);
|
||||
// Public channels include the whole team automatically; private channels
|
||||
// include only the members you explicitly select.
|
||||
const memberIds = priv ? selected : others.map((u) => u.id);
|
||||
const { channel } = await client.createChannel({ name: clean, topic: topic.trim() || undefined, kind: priv ? "private" : "channel", memberIds });
|
||||
refresh();
|
||||
closeModal();
|
||||
toast(`Created #${channel.name}`, "success");
|
||||
router.push(`/c/${channel.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Create a channel"
|
||||
subtitle="Channels are where your team communicates."
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={create} disabled={!name.trim() || busy || (priv && selected.length === 0)}>Create channel</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Name">
|
||||
<div className="flex items-center gap-2 rounded-control border border-border bg-panel px-3">
|
||||
{priv ? <Lock size={15} className="text-muted" /> : <Hash size={15} className="text-muted" />}
|
||||
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. marketing" className="w-full bg-transparent py-2 text-[14px] outline-none placeholder:text-dim" onKeyDown={(e) => e.key === "Enter" && create()} />
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Description (optional)">
|
||||
<input value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="What's this channel about?" className={inputCls} />
|
||||
</Field>
|
||||
<label className="flex items-center gap-2.5 rounded-control border border-border bg-panel px-3 py-2.5 text-[13px]">
|
||||
<input type="checkbox" checked={priv} onChange={(e) => setPriv(e.target.checked)} className="accent-[var(--c-accent)]" />
|
||||
<span><b>Make private</b> — only invited members can view</span>
|
||||
</label>
|
||||
|
||||
{priv ? (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 text-[12px] font-bold uppercase text-dim">Add members · {selected.length} selected</div>
|
||||
{selected.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{selected.map((id) => {
|
||||
const u = users.find((x) => x.id === id);
|
||||
if (!u) return null;
|
||||
return (
|
||||
<span key={id} className="inline-flex items-center gap-1.5 rounded-pill bg-accent-soft py-1 pl-1.5 pr-2 text-[12px] font-medium text-accent">
|
||||
<Avatar user={u} size={16} showPresence={false} /> {u.name}
|
||||
<button onClick={() => toggle(id)} className="text-accent/70 hover:text-accent">✕</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people to add…" className={inputCls} />
|
||||
<div className="mt-2 max-h-44 space-y-1 overflow-y-auto">
|
||||
{candidates.map((u) => (
|
||||
<button key={u.id} onClick={() => toggle(u.id)} className="flex w-full items-center gap-3 rounded-control px-2 py-1.5 text-left hover:bg-hover">
|
||||
<input type="checkbox" readOnly checked={selected.includes(u.id)} className="accent-[var(--c-accent)]" />
|
||||
<Avatar user={u} size={28} />
|
||||
<div className="min-w-0 flex-1"><div className="truncate text-[13.5px] font-medium">{u.name}</div><div className="truncate text-[11.5px] text-muted">{u.title}</div></div>
|
||||
</button>
|
||||
))}
|
||||
{candidates.length === 0 && <div className="py-4 text-center text-[12.5px] text-muted">No people match “{q}”.</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 flex items-center gap-2 rounded-control border border-border bg-panel px-3 py-2 text-[12.5px] text-muted">
|
||||
<Hash size={14} className="text-accent" /> Everyone on the team ({others.length}) will be added automatically.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NewMessageModal() {
|
||||
const { users, me, channels, client, refresh } = useSdk();
|
||||
const { closeModal, toast } = useUi();
|
||||
const router = useRouter();
|
||||
const [q, setQ] = useState("");
|
||||
const people = users.filter((u) => u.id !== me?.id && u.name.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
async function openDm(userId: string, name: string) {
|
||||
const existing = channels.find((c) => c.kind === "dm" && c.memberIds.includes(userId));
|
||||
closeModal();
|
||||
if (existing) { router.push(`/c/${existing.id}`); return; }
|
||||
const { channel } = await client.createChannel({ name, kind: "dm", memberIds: [userId] });
|
||||
refresh();
|
||||
toast(`Started a conversation with ${name}`, "success");
|
||||
router.push(`/c/${channel.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="New message" subtitle="Start a direct message" onClose={closeModal}>
|
||||
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" className={inputCls} />
|
||||
<div className="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
{people.map((u) => (
|
||||
<button key={u.id} onClick={() => openDm(u.id, u.name)} className="flex w-full items-center gap-3 rounded-control px-2 py-2 text-left hover:bg-hover">
|
||||
<Avatar user={u} size={34} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-medium">{u.name}</div>
|
||||
<div className="truncate text-[12px] text-muted">{u.title}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{people.length === 0 && <div className="py-6 text-center text-[13px] text-muted">No people match “{q}”.</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const [chips, setChips] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
|
||||
|
||||
const commit = (raw: string) => {
|
||||
const v = raw.trim().replace(/,$/, "").trim();
|
||||
if (!v) return;
|
||||
if (!isEmail(v)) { toast(`“${v}” doesn't look like an email`, "default"); return; }
|
||||
setChips((c) => (c.includes(v) ? c : [...c, v]));
|
||||
setDraft("");
|
||||
};
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "," || e.key === "Enter" || e.key === " ") { e.preventDefault(); commit(draft); }
|
||||
else if (e.key === "Backspace" && !draft && chips.length) { setChips((c) => c.slice(0, -1)); }
|
||||
};
|
||||
const total = chips.length + (isEmail(draft.trim()) ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Invite people"
|
||||
subtitle="Invite teammates to LynkedUp Pro"
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn disabled={total === 0} onClick={() => { const all = [...chips, ...(isEmail(draft.trim()) ? [draft.trim()] : [])]; closeModal(); toast(`${all.length} invitation${all.length === 1 ? "" : "s"} sent (demo)`, "success"); }}>Send invites</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Email addresses">
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-control border border-border bg-panel px-2 py-2">
|
||||
{chips.map((email) => (
|
||||
<span key={email} className="inline-flex items-center gap-1.5 rounded-pill bg-accent-soft py-1 pl-2.5 pr-1.5 text-[12.5px] font-medium text-accent">
|
||||
{email}
|
||||
<button onClick={() => setChips((c) => c.filter((x) => x !== email))} className="grid h-4 w-4 place-items-center rounded-full text-accent/70 hover:bg-accent/20 hover:text-accent">✕</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => { const v = e.target.value; if (v.endsWith(",")) commit(v); else setDraft(v); }}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={() => commit(draft)}
|
||||
placeholder={chips.length ? "" : "name@company.com, another@company.com"}
|
||||
className="min-w-[160px] flex-1 bg-transparent py-1 text-[14px] outline-none placeholder:text-dim"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<p className="text-[12px] text-muted">Type an email and press <b>,</b> or <b>Enter</b> to add it as a chip.</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWorkspaceModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const [name, setName] = useState("");
|
||||
return (
|
||||
<Modal
|
||||
title="Create a workspace"
|
||||
subtitle="Spin up a new workspace"
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn disabled={!name.trim()} onClick={() => { closeModal(); toast(`Workspace “${name}” created (demo)`, "success"); }}>Create</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Workspace name"><input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Acme Field Ops" className={inputCls} /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CallbackModal() {
|
||||
const { closeModal, toast } = useUi();
|
||||
const [channel, setChannel] = useState("PHONE");
|
||||
const [time, setTime] = useState("");
|
||||
return (
|
||||
<Modal
|
||||
title="Request a callback"
|
||||
subtitle="We'll schedule a call with the customer"
|
||||
onClose={closeModal}
|
||||
footer={<><GhostBtn onClick={closeModal}>Cancel</GhostBtn><PrimaryBtn onClick={() => { closeModal(); toast("Callback requested — scheduled follow-up created", "success"); }}>Request callback</PrimaryBtn></>}
|
||||
>
|
||||
<Field label="Preferred channel">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{["PHONE", "ZOOM", "IN_APP"].map((c) => (
|
||||
<button key={c} onClick={() => setChannel(c)} className={clsx("rounded-control border py-2 text-[12.5px] font-semibold", channel === c ? "border-accent bg-accent-soft text-accent" : "border-border hover:bg-hover")}>
|
||||
{c.replace("_", "-")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Preferred time (optional)"><input value={time} onChange={(e) => setTime(e.target.value)} placeholder="e.g. Tomorrow 2–4pm CT" className={inputCls} /></Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelDetailsModal() {
|
||||
const { modalArg, closeModal, openModal } = useUi();
|
||||
const { userById } = useSdk();
|
||||
const channel = useChannel(modalArg ?? null);
|
||||
if (!channel) { return <Modal title="Details" onClose={closeModal}><p className="text-[13px] text-muted">No channel selected.</p></Modal>; }
|
||||
return (
|
||||
<Modal title={channel.kind === "channel" ? `#${channel.name}` : channel.name} subtitle={channel.topic} onClose={closeModal} wide>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-control border border-border bg-panel p-3">
|
||||
<div className="text-[11px] font-semibold uppercase text-dim">Topic</div>
|
||||
<div className="mt-1 text-[13.5px]">{channel.topic || "No topic set"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold uppercase text-dim">Members · {channel.memberIds.length}</span>
|
||||
<button onClick={() => openModal("channel-members", channel.id)} className="focus-ring rounded-control border border-border px-2 py-1 text-[12px] font-semibold hover:bg-hover">+ Add people</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{channel.memberIds.map((id) => {
|
||||
const u = userById(id);
|
||||
return u ? (
|
||||
<div key={id} className="flex items-center gap-2 rounded-control border border-border bg-panel px-2.5 py-2">
|
||||
<Avatar user={u} size={28} />
|
||||
<div className="min-w-0"><div className="truncate text-[13px] font-medium">{u.name}</div><div className="truncate text-[11px] text-muted">{u.title}</div></div>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user