DB-backed workspace + LangGraph agent

This commit is contained in:
Ruslan Bakiev
2026-02-18 13:56:35 +07:00
parent a8db021597
commit efa0b79c4c
36 changed files with 2125 additions and 468 deletions

View File

@@ -148,351 +148,19 @@ function endAfter(startIso: string, minutes: number) {
return d.toISOString();
}
const feedCards = ref<FeedCard[]>([
{
id: "f1",
at: atOffset(0, 9, 35),
contact: "Anna Meyer",
text: "I analyzed Anna Meyer's latest activity: after a demo, the decision window is usually open for 1-2 hours. I suggest scheduling a follow-up immediately to keep momentum.",
proposal: {
title: "Add event to calendar",
details: [
"Contact: Anna Meyer",
"Start: 30 minutes from now",
"Duration: 30 minutes",
],
key: "create_followup",
},
decision: "pending",
},
{
id: "f2",
at: atOffset(0, 10, 8),
contact: "Murat Ali",
text: "I found that Murat Ali gave 3 quick replies in a row over the last hour. I suggest moving to a short call now while engagement is high.",
proposal: {
title: "Start a call and open chat",
details: [
"Contact: Murat Ali",
"Channel: Phone",
"After action: open the communication thread for this contact",
],
key: "call",
},
decision: "pending",
},
]);
const feedCards = ref<FeedCard[]>([]);
const contacts = ref<Contact[]>([
{
id: "c1",
name: "Anna Meyer",
avatar: "https://randomuser.me/api/portraits/women/44.jpg",
company: "Nordline GmbH",
country: "Germany",
location: "Berlin",
channels: ["Telegram", "Phone", "Email"],
lastContactAt: atOffset(-0, 10, 20),
description: "Decision owner for procurement. Prefers short, concrete updates with clear deadlines.\n\nBest pattern: one clear question per message, then one explicit next step.\n\nRisk: if timeline question is delayed, decision date keeps slipping.",
},
{
id: "c2",
name: "Murat Ali",
avatar: "https://randomuser.me/api/portraits/men/32.jpg",
company: "Connect FZCO",
country: "UAE",
location: "Dubai",
channels: ["WhatsApp", "Instagram", "Phone", "Email"],
lastContactAt: atOffset(-1, 18, 10),
description: "High activity in chat, fast response cycle.\n\nNeeds legal path clarity and explicit owner from his side.\n\nBest move: in next call, lock legal owner and target signature date.",
},
{
id: "c3",
name: "Ilya Petroff",
avatar: "https://randomuser.me/api/portraits/men/18.jpg",
company: "Volta Tech",
country: "Armenia",
location: "Yerevan",
channels: ["Email", "Phone"],
lastContactAt: atOffset(-1, 11, 12),
description: "Early-stage lead. Interested, but wants structured onboarding before commercial details.\n\nBest move: send concise onboarding plan + 2 time slots for intro session.",
},
{
id: "c4",
name: "Carlos Rivera",
avatar: "https://randomuser.me/api/portraits/men/65.jpg",
company: "BluePort",
country: "Spain",
location: "Barcelona",
channels: ["WhatsApp", "Email"],
lastContactAt: atOffset(-3, 14, 35),
description: "Pilot interest for two teams. Focus on rollout timeline and ownership per team.\n\nBest move: propose phased pilot with weekly checkpoints.",
},
{
id: "c5",
name: "Daria Ivanova",
avatar: "https://randomuser.me/api/portraits/women/22.jpg",
company: "Skyline Trade",
country: "Kazakhstan",
location: "Almaty",
channels: ["Telegram", "Phone"],
lastContactAt: atOffset(-4, 16, 10),
description: "Commercial discussion is blocked by ROI framing.\n\nBest move: provide short ROI model with three measurable outcomes.",
},
{
id: "c6",
name: "Ethan Moore",
avatar: "https://randomuser.me/api/portraits/men/76.jpg",
company: "NorthBridge",
country: "USA",
location: "Austin",
channels: ["Email", "Phone"],
lastContactAt: atOffset(-5, 12, 45),
description: "Prefers stable weekly cadence and structured updates.\n\nBest move: fixed weekly summary + one priority ask per cycle.",
},
]);
const contacts = ref<Contact[]>([]);
const calendarEvents = ref<CalendarEvent[]>([
{
id: "e1",
title: "Anna follow-up",
start: inMinutes(12),
end: endAfter(inMinutes(12), 30),
contact: "Anna Meyer",
note: "Confirm decision date and next owner.",
},
{
id: "e2",
title: "Murat contract call",
start: atOffset(2, 16, 30),
end: endAfter(atOffset(2, 16, 30), 30),
contact: "Murat Ali",
note: "Lock legal owner and signing target.",
},
{
id: "e3",
title: "Ilya discovery",
start: atOffset(3, 11, 0),
end: endAfter(atOffset(3, 11, 0), 30),
contact: "Ilya Petroff",
note: "Qualify onboarding readiness.",
},
{
id: "e4",
title: "Internal sync",
start: atOffset(0, 9, 0),
end: endAfter(atOffset(0, 9, 0), 30),
contact: "Team",
note: "Align next actions across accounts.",
},
{
id: "e5",
title: "BluePort intro",
start: atOffset(4, 13, 0),
end: endAfter(atOffset(4, 13, 0), 30),
contact: "Carlos Rivera",
note: "Check pilot scope and timeline.",
},
]);
const calendarEvents = ref<CalendarEvent[]>([]);
const commItems = ref<CommItem[]>([
{
id: "m1",
at: atOffset(0, 9, 20),
contact: "Anna Meyer",
channel: "Telegram",
kind: "message",
direction: "in",
text: "Can you share final pricing today?",
},
{
id: "m2",
at: atOffset(0, 9, 22),
contact: "Anna Meyer",
channel: "Telegram",
kind: "message",
direction: "out",
text: "Yes, sending after demo.",
},
{
id: "m3",
at: atOffset(0, 10, 0),
contact: "Anna Meyer",
channel: "Phone",
kind: "call",
direction: "out",
text: "Quick sync call",
duration: "14m",
transcript: [
"Anna: We can review pricing today, but I need one clear next step.",
"You: Agreed. I will send the final version right after this call.",
"Anna: Perfect. Add a follow-up tomorrow morning.",
],
},
{
id: "m4",
at: atOffset(-1, 15, 5),
contact: "Murat Ali",
channel: "WhatsApp",
kind: "message",
direction: "in",
text: "Need to shift call by one day.",
},
{
id: "m5",
at: atOffset(-1, 15, 8),
contact: "Murat Ali",
channel: "WhatsApp",
kind: "message",
direction: "out",
text: "Works. Let us do 12:00 tomorrow.",
},
{
id: "m6",
at: atOffset(-1, 18, 10),
contact: "Murat Ali",
channel: "Phone",
kind: "call",
direction: "in",
text: "Contract call",
duration: "9m",
transcript: [
"Murat: We can move forward if legal owner is confirmed today.",
"You: Understood. I will lock owner and send the timeline.",
"Murat: Then we target signature this week.",
],
},
{
id: "m9",
at: atOffset(-1, 13, 40),
contact: "Murat Ali",
channel: "Instagram",
kind: "message",
direction: "in",
text: "Sent details in DM, can we align tomorrow?",
},
{
id: "m7",
at: atOffset(-1, 11, 12),
contact: "Ilya Petroff",
channel: "Email",
kind: "message",
direction: "in",
text: "Interested in pilot, what are next steps?",
},
{
id: "m8",
at: atOffset(-1, 12, 0),
contact: "Ilya Petroff",
channel: "Phone",
kind: "call",
direction: "out",
text: "Missed callback",
duration: "-",
transcript: [
"No full transcript available. Call was not connected.",
],
},
]);
const commItems = ref<CommItem[]>([]);
const commPins = ref<CommPin[]>([
{
id: "cp-1",
contact: "Anna Meyer",
text: "First lock the decision date, then send the final offer.",
},
{
id: "cp-2",
contact: "Anna Meyer",
text: "A short follow-up is needed no later than 30 minutes after the demo.",
},
{
id: "cp-3",
contact: "Murat Ali",
text: "In every update, confirm the legal owner on the client side.",
},
{
id: "cp-4",
contact: "Ilya Petroff",
text: "Work through a structured onboarding plan, not pricing first.",
},
]);
const commPins = ref<CommPin[]>([]);
const deals = ref<Deal[]>([
{
id: "d1",
contact: "Anna Meyer",
title: "Nordline pilot",
company: "Nordline GmbH",
stage: "Negotiation",
amount: "$28,000",
nextStep: "Send post-demo offer within 30 minutes",
summary: "Active thread, fast feedback, high close probability this week.",
},
{
id: "d2",
contact: "Murat Ali",
title: "Connect annual",
company: "Connect FZCO",
stage: "Legal review",
amount: "$74,000",
nextStep: "Lock legal owner and expected sign date",
summary: "Strong engagement in chat and calls, pending legal alignment.",
},
{
id: "d3",
contact: "Ilya Petroff",
title: "Volta onboarding",
company: "Volta Tech",
stage: "Discovery",
amount: "$12,000",
nextStep: "Offer two onboarding slots for tomorrow",
summary: "Early stage interest, needs concise onboarding plan.",
},
]);
const deals = ref<Deal[]>([]);
const documents = ref<WorkspaceDocument[]>([
{
id: "doc-1",
title: "Outbound cadence v1",
type: "Regulation",
owner: "Revenue Ops",
scope: "All B2B accounts",
updatedAt: atOffset(-1, 10, 0),
summary: "Unified sequence for first touch, follow-up, and qualification.",
body: "## Goal\nMove a new contact to the first qualified call within 5 business days.\n\n## Base sequence\n- Day 0: first message in the primary channel.\n- Day 1: short follow-up with one clear ask.\n- Day 3: second follow-up + alternate channel.\n- Day 5: final ping and move to \"later\".\n\n## Rules\n- Always keep one explicit next step in each message.\n- Avoid long text walls.\n- After each reply, update context in the contact card.",
},
{
id: "doc-2",
title: "Discovery call playbook",
type: "Playbook",
owner: "Sales Lead",
scope: "Discovery calls",
updatedAt: atOffset(-2, 12, 15),
summary: "Call structure, mandatory questions, and outcome logging format.",
body: "## Structure\n1. Context check (2 min)\n2. Current pain (8 min)\n3. Success criteria (6 min)\n4. Next step lock (4 min)\n\n## Mandatory outcomes\n- Confirmed business owner\n- Confirmed decision timeline\n- Confirmed next meeting date\n\n## Notes format\nAlways log: pain, impact, owner, ETA, risks.",
},
{
id: "doc-3",
title: "AI assistant operating policy",
type: "Policy",
owner: "Founders",
scope: "AI recommendations and automations",
updatedAt: atOffset(-3, 9, 40),
summary: "What actions AI can suggest and what requires explicit approval.",
body: "## Allowed without approval\n- Draft message suggestions\n- Calendar proposal suggestions\n- Conversation summaries\n\n## Requires explicit approval\n- Sending a message to an external contact\n- Creating a calendar event\n- Changing deal stage\n\n## Logging\nEvery AI action must leave a short trace in the feed.",
},
{
id: "doc-4",
title: "Post-call follow-up template",
type: "Template",
owner: "Enablement",
scope: "Any completed client call",
updatedAt: atOffset(-4, 16, 20),
summary: "Template for short post-call follow-up with aligned actions.",
body: "## Message template\nThanks for the call. Summary below:\n- What we aligned on\n- What remains open\n- Owner per action\n- Exact date for next sync\n\n## Quality bar\nThe client should understand the next step within 10 seconds.",
},
]);
const documents = ref<WorkspaceDocument[]>([]);
type PilotMessage = {
id: string;
@@ -506,12 +174,83 @@ type PilotMessage = {
const pilotMessages = ref<PilotMessage[]>([]);
const pilotInput = ref("");
const pilotSending = ref(false);
const authMe = ref<{ user: { id: string; email: string; name: string }; team: { id: string; name: string } } | null>(
null,
);
const loginEmail = ref("");
const loginName = ref("");
const loginTeamName = ref("My Team");
const loginError = ref<string | null>(null);
const loginBusy = ref(false);
async function loadPilotMessages() {
const res = await $fetch<{ items: PilotMessage[] }>("/api/chat");
pilotMessages.value = res.items ?? [];
}
async function loadMe() {
authMe.value = await $fetch("/api/auth/me");
}
async function login() {
loginError.value = null;
loginBusy.value = true;
try {
await $fetch("/api/auth/login", {
method: "POST",
body: { email: loginEmail.value, name: loginName.value, teamName: loginTeamName.value },
});
await loadMe();
await Promise.all([loadPilotMessages(), refreshCrmData()]);
} catch (e: any) {
loginError.value = e?.data?.message || e?.message || "Login failed";
} finally {
loginBusy.value = false;
}
}
async function logout() {
await $fetch("/api/auth/logout", { method: "POST" });
authMe.value = null;
}
async function useDemo() {
await $fetch("/api/auth/demo", { method: "POST" });
await loadMe();
await Promise.all([loadPilotMessages(), refreshCrmData()]);
}
async function refreshCrmData() {
const [contactsRes, commRes, calRes, dealsRes, feedRes, pinsRes, docsRes] = await Promise.all([
$fetch<{ items: Contact[] }>("/api/contacts"),
$fetch<{ items: CommItem[] }>("/api/communications"),
$fetch<{ items: CalendarEvent[] }>("/api/calendar"),
$fetch<{ items: Deal[] }>("/api/deals"),
$fetch<{ items: FeedCard[] }>("/api/feed"),
$fetch<{ items: CommPin[] }>("/api/pins"),
$fetch<{ items: WorkspaceDocument[] }>("/api/documents"),
]);
contacts.value = contactsRes.items ?? [];
commItems.value = commRes.items ?? [];
calendarEvents.value = calRes.items ?? [];
deals.value = dealsRes.items ?? [];
feedCards.value = feedRes.items ?? [];
commPins.value = pinsRes.items ?? [];
documents.value = docsRes.items ?? [];
// Derive channels per contact from communication items.
const byName = new Map<string, Set<string>>();
for (const item of commItems.value) {
if (!byName.has(item.contact)) byName.set(item.contact, new Set());
byName.get(item.contact)?.add(item.channel);
}
contacts.value = contacts.value.map((c) => ({
...c,
channels: Array.from(byName.get(c.name) ?? []),
}));
}
async function sendPilotMessage() {
const text = pilotInput.value.trim();
if (!text || pilotSending.value) return;
@@ -527,7 +266,9 @@ async function sendPilotMessage() {
}
onMounted(() => {
loadPilotMessages();
loadMe()
.then(() => Promise.all([loadPilotMessages(), refreshCrmData()]))
.catch(() => {});
});
const calendarView = ref<CalendarView>("month");
@@ -1179,7 +920,8 @@ function openMessageFromContact(channel: CommItem["channel"]) {
selectedCommChannel.value = channel;
}
function executeFeedAction(key: FeedCard["proposal"]["key"]) {
async function executeFeedAction(card: FeedCard) {
const key = card.proposal.key;
if (key === "create_followup") {
const start = new Date();
start.setMinutes(start.getMinutes() + 30);
@@ -1187,54 +929,62 @@ function executeFeedAction(key: FeedCard["proposal"]["key"]) {
const end = new Date(start);
end.setMinutes(end.getMinutes() + 30);
calendarEvents.value.unshift({
id: makeId("e"),
title: "Follow-up: Anna",
start: start.toISOString(),
end: end.toISOString(),
contact: "Anna Meyer",
note: "Created from feed action.",
const res = await $fetch<{ item: CalendarEvent }>("/api/calendar", {
method: "POST",
body: {
title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`,
start: start.toISOString(),
end: end.toISOString(),
contact: card.contact,
note: "Created from feed action.",
status: "planned",
},
});
calendarEvents.value = [res.item, ...calendarEvents.value];
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
return `Event created: Follow-up: Anna · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())}`;
return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
}
if (key === "open_comm") {
openCommunicationThread("Murat Ali");
return "Opened Murat Ali communication thread.";
openCommunicationThread(card.contact);
return `Opened ${card.contact} communication thread.`;
}
if (key === "call") {
commItems.value.push({
id: makeId("m"),
at: new Date().toISOString(),
contact: "Murat Ali",
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
duration: "00:00",
await $fetch("/api/communications", {
method: "POST",
body: {
contact: card.contact,
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
durationSec: 0,
},
});
openCommunicationThread("Murat Ali");
return "Call event created and Murat Ali chat opened.";
await refreshCrmData();
openCommunicationThread(card.contact);
return `Call event created and ${card.contact} chat opened.`;
}
if (key === "draft_message") {
commItems.value.push({
id: makeId("m"),
at: new Date().toISOString(),
contact: "Ilya Petroff",
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
await $fetch("/api/communications", {
method: "POST",
body: {
contact: card.contact,
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
},
});
openCommunicationThread("Ilya Petroff");
return "Draft message added to Ilya Petroff communications.";
await refreshCrmData();
openCommunicationThread(card.contact);
return `Draft message added to ${card.contact} communications.`;
}
if (key === "run_summary") {
@@ -1242,32 +992,38 @@ function executeFeedAction(key: FeedCard["proposal"]["key"]) {
}
if (key === "prepare_question") {
commItems.value.push({
id: makeId("m"),
at: new Date().toISOString(),
contact: "Anna Meyer",
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
await $fetch("/api/communications", {
method: "POST",
body: {
contact: card.contact,
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
},
});
openCommunicationThread("Anna Meyer");
return "Question about decision date added to Anna Meyer chat.";
await refreshCrmData();
openCommunicationThread(card.contact);
return `Question about decision date added to ${card.contact} chat.`;
}
return "Action completed.";
}
function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
card.decision = decision;
if (decision === "rejected") {
card.decisionNote = "Rejected. Nothing created.";
const note = "Rejected. Nothing created.";
card.decisionNote = note;
await $fetch(`/api/feed/${card.id}`, { method: "PUT", body: { decision: "rejected", decisionNote: note } });
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
return;
}
const result = executeFeedAction(card.proposal.key);
const result = await executeFeedAction(card);
card.decisionNote = result;
await $fetch(`/api/feed/${card.id}`, { method: "PUT", body: { decision: "accepted", decisionNote: result } });
pushPilotNote(`[${card.contact}] ${result}`);
}
@@ -1278,56 +1034,84 @@ function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
<div class="grid gap-3 lg:grid-cols-12 lg:gap-4">
<aside class="card min-h-0 border border-base-300 bg-base-100 shadow-sm lg:col-span-3 lg:sticky lg:top-5 lg:h-[calc(100vh-2.5rem)]">
<div class="card-body h-full min-h-0 p-3 md:p-4">
<div class="mb-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-base-content/70">Pilot</h2>
<div class="mb-2 flex items-start justify-between gap-2">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wide text-base-content/70">Pilot</h2>
<p v-if="authMe" class="mt-1 text-xs text-base-content/60">
{{ authMe.team.name }} · {{ authMe.user.name }}
</p>
</div>
<button v-if="authMe" class="btn btn-ghost btn-xs" @click="logout">Logout</button>
</div>
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border border-base-300 p-2">
<div
v-for="message in pilotMessages"
:key="message.id"
class="chat"
:class="message.role === 'assistant' ? 'chat-start' : 'chat-end'"
>
<div class="space-y-2">
<div class="chat-bubble text-sm" :class="message.role === 'assistant' ? 'chat-bubble-neutral' : ''">
{{ message.text }}
</div>
<div v-if="!authMe" class="space-y-3">
<div class="rounded-xl border border-base-300 bg-base-100 p-3">
<p class="text-sm font-semibold">Login</p>
<p class="mt-1 text-xs text-base-content/60">MVP: email + name. Потом подключим нормальную auth.</p>
<details
v-if="message.role === 'assistant' && ((message.plan && message.plan.length) || (message.tools && message.tools.length))"
class="rounded-lg border border-base-300 bg-base-100 p-2 text-xs"
>
<summary class="cursor-pointer select-none font-semibold text-base-content/70">Plan & tools</summary>
<div class="mt-2 space-y-2">
<div v-if="message.plan && message.plan.length">
<div class="font-semibold text-base-content/70">Plan</div>
<ul class="list-disc pl-4">
<li v-for="(step, idx) in message.plan" :key="`plan-${message.id}-${idx}`">{{ step }}</li>
</ul>
</div>
<div v-if="message.tools && message.tools.length">
<div class="font-semibold text-base-content/70">Tools</div>
<ul class="list-disc pl-4">
<li v-for="(tool, idx) in message.tools" :key="`tools-${message.id}-${idx}`">{{ tool }}</li>
</ul>
</div>
</div>
</details>
<div class="mt-3 space-y-2">
<input v-model="loginEmail" type="email" class="input input-bordered input-sm w-full" placeholder="Email" />
<input v-model="loginName" type="text" class="input input-bordered input-sm w-full" placeholder="Name" />
<input v-model="loginTeamName" type="text" class="input input-bordered input-sm w-full" placeholder="Team name" />
<p v-if="loginError" class="text-xs text-error">{{ loginError }}</p>
<button class="btn btn-sm w-full" :disabled="loginBusy" @click="login">
{{ loginBusy ? "Logging in..." : "Login" }}
</button>
<button class="btn btn-ghost btn-sm w-full" :disabled="loginBusy" @click="useDemo">
Use demo
</button>
</div>
</div>
</div>
<div class="mt-2 flex gap-2">
<input
v-model="pilotInput"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Write a message..."
@keyup.enter="sendPilotMessage"
>
<button class="btn btn-sm" @click="sendPilotMessage">Send</button>
</div>
<template v-else>
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border border-base-300 p-2">
<div
v-for="message in pilotMessages"
:key="message.id"
class="chat"
:class="message.role === 'assistant' ? 'chat-start' : 'chat-end'"
>
<div class="space-y-2">
<div class="chat-bubble text-sm" :class="message.role === 'assistant' ? 'chat-bubble-neutral' : ''">
{{ message.text }}
</div>
<details
v-if="message.role === 'assistant' && ((message.plan && message.plan.length) || (message.tools && message.tools.length))"
class="rounded-lg border border-base-300 bg-base-100 p-2 text-xs"
>
<summary class="cursor-pointer select-none font-semibold text-base-content/70">Plan & tools</summary>
<div class="mt-2 space-y-2">
<div v-if="message.plan && message.plan.length">
<div class="font-semibold text-base-content/70">Plan</div>
<ul class="list-disc pl-4">
<li v-for="(step, idx) in message.plan" :key="`plan-${message.id}-${idx}`">{{ step }}</li>
</ul>
</div>
<div v-if="message.tools && message.tools.length">
<div class="font-semibold text-base-content/70">Tools</div>
<ul class="list-disc pl-4">
<li v-for="(tool, idx) in message.tools" :key="`tools-${message.id}-${idx}`">{{ tool }}</li>
</ul>
</div>
</div>
</details>
</div>
</div>
</div>
<div class="mt-2 flex gap-2">
<input
v-model="pilotInput"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Write a message..."
@keyup.enter="sendPilotMessage"
>
<button class="btn btn-sm" :disabled="pilotSending" @click="sendPilotMessage">Send</button>
</div>
</template>
</div>
</aside>