All checks were successful
build-and-release / build (push) Successful in 1m52s
- Added confirmSaveProfile() — calls POST /api/ai/chat/confirm with action='save_profile', extracting the quoted improved text from the LLM's message automatically - Green 'Save to Profile' button appears on profile_draft messages (actionType === 'profile_draft'), distinct from the ticket confirm flow - actionLabel() extended with 'save_profile' key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
690 lines
24 KiB
TypeScript
690 lines
24 KiB
TypeScript
import { createSignal, Show, For, onMount } from "solid-js";
|
|
import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid";
|
|
|
|
const API = "/api";
|
|
|
|
interface ChatMessage {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
intent?: string;
|
|
status?: string;
|
|
suggestedAction?: string;
|
|
/** Semantic category: ticket_created | ticket_pending | kb_results | usage_info | navigation */
|
|
actionType?: string;
|
|
/** Original user text — stored so ticket confirm has a description to send */
|
|
userQuery?: string;
|
|
/** True once the user has clicked the confirm/action button on this message */
|
|
confirmed?: boolean;
|
|
}
|
|
|
|
interface ChatResponse {
|
|
reply: string;
|
|
conversation_id: string;
|
|
intent: string;
|
|
confidence: number;
|
|
remaining_credits?: number;
|
|
remaining_daily_actions?: number;
|
|
credits_charged?: number;
|
|
message?: string;
|
|
status?: string;
|
|
suggested_action?: string;
|
|
action_type?: string;
|
|
}
|
|
|
|
function statusLabel(status?: string): string | null {
|
|
switch (status) {
|
|
case "ticket_created":
|
|
return "Support ticket created";
|
|
case "kb_results":
|
|
return "Help articles found";
|
|
case "usage_summary":
|
|
return "AI usage summary";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Returns the dashboard URL to navigate to for a given suggested_action key. */
|
|
function navUrl(action: string | undefined): string | null {
|
|
switch (action) {
|
|
case "open_support_ticket": return "/dashboard?nav=support";
|
|
case "open_help_search": return "/help-center";
|
|
case "open_account_settings": return "/dashboard?nav=settings";
|
|
case "open_billing":
|
|
case "show_usage_modal": return "/dashboard?nav=credits";
|
|
case "open_jd_generator": return "/dashboard?nav=job_descriptions";
|
|
case "open_cover_letter": return "/dashboard?nav=cover_letter";
|
|
case "open_resume_tailor": return "/dashboard?nav=resume";
|
|
case "open_lead_unlock": return "/dashboard?nav=leads";
|
|
case "open_auto_apply": return "/dashboard?nav=auto_apply";
|
|
case "open_form_extract": return "/dashboard?nav=form_extract";
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
/** Human-readable label for an action button. */
|
|
function actionLabel(action: string | undefined): string {
|
|
switch (action) {
|
|
case "open_support_ticket": return "View Tickets →";
|
|
case "open_help_search": return "Browse Help Center →";
|
|
case "open_account_settings": return "Account Settings →";
|
|
case "open_billing":
|
|
case "show_usage_modal": return "AI Credits & Billing →";
|
|
case "open_jd_generator": return "Job Description Generator →";
|
|
case "open_cover_letter": return "Cover Letter Generator →";
|
|
case "open_resume_tailor": return "Resume Tailor →";
|
|
case "open_lead_unlock": return "Lead Requests →";
|
|
case "open_auto_apply": return "Auto Apply →";
|
|
case "open_form_extract": return "Form Assistant →";
|
|
case "create_ticket": return "Create Support Ticket";
|
|
case "save_profile": return "Save to Profile";
|
|
default: return "";
|
|
}
|
|
}
|
|
|
|
interface UsageStatus {
|
|
remaining_credits: number;
|
|
remaining_daily_actions: number;
|
|
daily_action_limit: number;
|
|
plan_code: string;
|
|
plan_name: string;
|
|
monthly_credits_total: number;
|
|
monthly_credits_used: number;
|
|
}
|
|
|
|
export function AiChatWidget() {
|
|
const [isOpen, setIsOpen] = createSignal(false);
|
|
const [messages, setMessages] = createSignal<ChatMessage[]>([
|
|
{
|
|
role: "assistant",
|
|
content:
|
|
"Hi! I'm Ask Ash, your Nxtgauge assistant. I can help you:\n• Search help articles & KB\n• Create support tickets\n• Explain your AI plan & usage\n• Answer questions about the platform",
|
|
},
|
|
]);
|
|
const [input, setInput] = createSignal("");
|
|
const [isLoading, setIsLoading] = createSignal(false);
|
|
const [conversationId, setConversationId] = createSignal("");
|
|
const [usage, setUsage] = createSignal<UsageStatus | null>(null);
|
|
|
|
onMount(() => {
|
|
const hasToken = typeof window !== "undefined" && !!sessionStorage.getItem("nxtgauge_access_token");
|
|
if (hasToken) fetchUsage();
|
|
});
|
|
|
|
const fetchUsage = async () => {
|
|
try {
|
|
const res = await fetch(`${API}/ai/usage/summary`, {
|
|
headers: {
|
|
Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`,
|
|
},
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
const plan = data.plan_details || data.plan || {};
|
|
setUsage({
|
|
remaining_credits: plan.remaining_credits ?? data.monthly_remaining ?? 0,
|
|
remaining_daily_actions: plan.remaining_daily_actions ?? data.daily_remaining ?? 0,
|
|
daily_action_limit: plan.daily_action_limit ?? data.daily_limit ?? 0,
|
|
plan_code: plan.plan_code ?? data.plan_code ?? "free",
|
|
plan_name: plan.plan_name ?? data.plan ?? "Free",
|
|
monthly_credits_total: plan.monthly_credits_total ?? data.monthly_limit ?? 0,
|
|
monthly_credits_used: plan.monthly_credits_used ?? data.monthly_used ?? 0,
|
|
});
|
|
} catch (err) {
|
|
console.error("Failed to fetch AI usage", err);
|
|
}
|
|
};
|
|
|
|
const toggleChat = () => setIsOpen((v) => !v);
|
|
|
|
const sendMessage = async () => {
|
|
const text = input().trim();
|
|
if (!text || isLoading()) return;
|
|
|
|
setIsLoading(true);
|
|
const userMessage: ChatMessage = { role: "user", content: text };
|
|
setMessages((prev) => [...prev, userMessage]);
|
|
setInput("");
|
|
|
|
try {
|
|
let res = await fetch(`${API}/ai/chat/ask`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
message: text,
|
|
conversation_id: conversationId() || undefined,
|
|
}),
|
|
});
|
|
|
|
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
res = await fetch(`${API}/ai/chat/message`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
message: text,
|
|
conversation_id: conversationId() || undefined,
|
|
}),
|
|
});
|
|
}
|
|
|
|
if (!res.ok) throw new Error("AI request failed");
|
|
|
|
const data: ChatResponse = await res.json();
|
|
if (data.conversation_id && !conversationId()) {
|
|
setConversationId(data.conversation_id);
|
|
}
|
|
|
|
// Update usage state from response if available
|
|
if (typeof data.remaining_credits === "number") {
|
|
setUsage((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
remaining_credits: data.remaining_credits!,
|
|
remaining_daily_actions: data.remaining_daily_actions ?? prev.remaining_daily_actions,
|
|
}
|
|
: null
|
|
);
|
|
}
|
|
|
|
const assistantMessage: ChatMessage = {
|
|
role: "assistant",
|
|
content: data.message || data.reply,
|
|
intent: data.intent,
|
|
status: data.status,
|
|
suggestedAction: data.suggested_action,
|
|
actionType: data.action_type,
|
|
userQuery: text,
|
|
confirmed: false,
|
|
};
|
|
setMessages((prev) => [...prev, assistantMessage]);
|
|
} catch (err) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content:
|
|
"I'm having trouble connecting right now. Please try again or contact support@nxtgauge.com.",
|
|
},
|
|
]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
};
|
|
|
|
/** Called when user clicks "Create Support Ticket" on a ticket_pending message. */
|
|
const confirmCreateTicket = async (msgIdx: number, userQuery: string) => {
|
|
// Mark the originating message as confirmed so the button disappears immediately
|
|
setMessages((prev) => prev.map((m, i) => (i === msgIdx ? { ...m, confirmed: true } : m)));
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch(`${API}/ai/chat/confirm`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`,
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
action: "create_ticket",
|
|
conversation_id: conversationId() || undefined,
|
|
fields: {
|
|
subject: userQuery.slice(0, 80),
|
|
description: userQuery,
|
|
category: "ai_assisted",
|
|
priority: "medium",
|
|
},
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (res.ok && data.success) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: data.message || `Support ticket created. Our team will get back to you shortly.`,
|
|
status: "ticket_created",
|
|
suggestedAction: "open_support_ticket",
|
|
actionType: "ticket_created",
|
|
confirmed: false,
|
|
},
|
|
]);
|
|
} else {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: data.error || "Couldn't create the ticket right now. Please try again or email support@nxtgauge.com.",
|
|
},
|
|
]);
|
|
}
|
|
} catch {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: "Couldn't connect to create the ticket. Please email support@nxtgauge.com directly.",
|
|
},
|
|
]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
/** Called when user clicks "Save to Profile" on a profile_draft message. */
|
|
const confirmSaveProfile = async (msgIdx: number, draftText: string) => {
|
|
// Extract just the quoted improved text if it's wrapped in our "Here's an improved..." message
|
|
const quotedMatch = draftText.match(/"([^"]+)"/);
|
|
const cleanDraft = quotedMatch ? quotedMatch[1] : draftText;
|
|
|
|
setMessages((prev) => prev.map((m, i) => (i === msgIdx ? { ...m, confirmed: true } : m)));
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch(`${API}/ai/chat/confirm`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`,
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
action: "save_profile",
|
|
conversation_id: conversationId() || undefined,
|
|
fields: { draft_text: cleanDraft },
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (res.ok && data.success) {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: data.message || "Your profile summary has been updated!",
|
|
status: "profile_saved",
|
|
confirmed: true,
|
|
},
|
|
]);
|
|
} else {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: data.error || "Couldn't save the profile summary. Please try again or update it manually from your profile page.",
|
|
},
|
|
]);
|
|
}
|
|
} catch {
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
role: "assistant",
|
|
content: "Couldn't connect to save your profile. Please update it manually from your profile page.",
|
|
},
|
|
]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Floating button */}
|
|
<button
|
|
onClick={toggleChat}
|
|
style={{
|
|
position: "fixed",
|
|
bottom: "24px",
|
|
right: "24px",
|
|
width: "56px",
|
|
height: "56px",
|
|
"border-radius": "50%",
|
|
background: "#FF5E13",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
"align-items": "center",
|
|
"justify-content": "center",
|
|
"box-shadow": "0 4px 16px rgba(255, 90, 19, 0.35)",
|
|
"z-index": "9999",
|
|
transition: "transform 0.2s",
|
|
}}
|
|
title="AI Assistant"
|
|
aria-label={isOpen() ? "Close AI Assistant" : "Open AI Assistant"}
|
|
aria-expanded={isOpen()}
|
|
>
|
|
<Show when={isOpen()} fallback={<MessageCircle size={24} color="#fff" />}>
|
|
<X size={24} color="#fff" />
|
|
</Show>
|
|
</button>
|
|
|
|
{/* Chat window */}
|
|
<Show when={isOpen()}>
|
|
<div
|
|
role="dialog"
|
|
aria-label="AI Assistant chat"
|
|
aria-modal="true"
|
|
style={{
|
|
position: "fixed",
|
|
bottom: "96px",
|
|
right: "24px",
|
|
width: "380px",
|
|
height: "520px",
|
|
background: "#fff",
|
|
"border-radius": "16px",
|
|
"box-shadow": "0 8px 40px rgba(0,0,0,0.15)",
|
|
display: "flex",
|
|
"flex-direction": "column",
|
|
overflow: "hidden",
|
|
"z-index": "9998",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
background: "linear-gradient(135deg, #FF5E13 0%, #E5470F 100%)",
|
|
padding: "16px 20px",
|
|
display: "flex",
|
|
"align-items": "center",
|
|
"justify-content": "space-between",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", "align-items": "center", gap: "10px" }}>
|
|
<img
|
|
src="/ai-assistant-logo.png"
|
|
alt="AI Assistant"
|
|
style={{ width: "26px", height: "26px", "border-radius": "6px", "object-fit": "contain", "background": "transparent" }}
|
|
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
|
/>
|
|
<div>
|
|
<p style={{ margin: 0, color: "#fff", "font-weight": "700", "font-size": "15px" }}>
|
|
AI Assistant
|
|
</p>
|
|
<Show when={usage()}>
|
|
{(u) => (
|
|
<p style={{ margin: "2px 0 0", color: "rgba(255,255,255,0.85)", "font-size": "11px" }}>
|
|
{u().plan_name} · {u().remaining_credits} credits · {u().remaining_daily_actions}/{u().daily_action_limit} today
|
|
</p>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={toggleChat}
|
|
aria-label="Close chat"
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
padding: "4px",
|
|
}}
|
|
>
|
|
<X size={20} color="#fff" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Quick actions */}
|
|
<div
|
|
style={{
|
|
padding: "10px 16px",
|
|
"border-bottom": "1px solid #E5E7EB",
|
|
display: "flex",
|
|
gap: "8px",
|
|
"flex-wrap": "wrap",
|
|
}}
|
|
>
|
|
<For each={[
|
|
{ label: "Support Ticket", text: "I need help with " },
|
|
{ label: "Search KB", text: "How do I " },
|
|
{ label: "AI Plan", text: "Explain my AI plan" },
|
|
{ label: "Check Balance", text: "Check my AI balance" },
|
|
]}>{(action) => (
|
|
<button
|
|
aria-label={`Quick action: ${action.label}`}
|
|
onClick={() => {
|
|
setInput(action.text);
|
|
}}
|
|
style={{
|
|
padding: "4px 10px",
|
|
"border-radius": "20px",
|
|
border: "1px solid #E5E7EB",
|
|
background: "#F9FAFB",
|
|
"font-size": "11px",
|
|
cursor: "pointer",
|
|
color: "#374151",
|
|
}}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
)}</For>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflow: "auto",
|
|
padding: "16px",
|
|
display: "flex",
|
|
"flex-direction": "column",
|
|
gap: "12px",
|
|
}}
|
|
>
|
|
<For each={messages()}>
|
|
{(msg, idx) => (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
"align-items": "flex-start",
|
|
gap: "8px",
|
|
"flex-direction": msg.role === "user" ? "row-reverse" : "row",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
width: "28px",
|
|
height: "28px",
|
|
"border-radius": "50%",
|
|
background: msg.role === "user" ? "#FF5E13" : "#E5E7EB",
|
|
display: "flex",
|
|
"align-items": "center",
|
|
"justify-content": "center",
|
|
"flex-shrink": 0,
|
|
}}
|
|
>
|
|
<Show when={msg.role === "user"} fallback={<Bot size={14} color="#6B7280" />}>
|
|
<User size={14} color="#fff" />
|
|
</Show>
|
|
</div>
|
|
<div
|
|
style={{
|
|
"max-width": "75%",
|
|
padding: "10px 14px",
|
|
"border-radius": "14px",
|
|
background: msg.role === "user" ? "#FF5E13" : "#F3F4F6",
|
|
color: msg.role === "user" ? "#fff" : "#111827",
|
|
"font-size": "13px",
|
|
"line-height": "1.5",
|
|
}}
|
|
>
|
|
<p style={{ margin: 0, "white-space": "pre-wrap" }}>{msg.content}</p>
|
|
<Show when={msg.role === "assistant" && statusLabel(msg.status)}>
|
|
{(label) => (
|
|
<p
|
|
style={{
|
|
margin: "6px 0 0",
|
|
"font-size": "10px",
|
|
color: "#6B7280",
|
|
"font-weight": "600",
|
|
}}
|
|
>
|
|
{label()}
|
|
</p>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons — only for unconfirmed assistant messages with a suggested action */}
|
|
<Show when={msg.role === "assistant" && msg.suggestedAction && !msg.confirmed}>
|
|
<div
|
|
style={{
|
|
"padding-left": "36px",
|
|
"margin-top": "-4px",
|
|
display: "flex",
|
|
gap: "6px",
|
|
"flex-wrap": "wrap",
|
|
}}
|
|
>
|
|
{/* Navigation button — links to the relevant dashboard section */}
|
|
<Show when={msg.suggestedAction !== "create_ticket" && navUrl(msg.suggestedAction)}>
|
|
{(url) => (
|
|
<a
|
|
href={url()}
|
|
style={{
|
|
display: "inline-flex",
|
|
"align-items": "center",
|
|
padding: "5px 12px",
|
|
background: "#FFF5F0",
|
|
border: "1px solid #FF5E13",
|
|
"border-radius": "20px",
|
|
color: "#FF5E13",
|
|
"font-size": "11px",
|
|
"font-weight": "600",
|
|
"text-decoration": "none",
|
|
cursor: "pointer",
|
|
transition: "background 0.15s",
|
|
}}
|
|
>
|
|
{actionLabel(msg.suggestedAction)}
|
|
</a>
|
|
)}
|
|
</Show>
|
|
|
|
{/* Confirm-create button — for ticket_pending messages */}
|
|
<Show when={msg.suggestedAction === "create_ticket" || msg.actionType === "ticket_pending"}>
|
|
<button
|
|
onClick={() => confirmCreateTicket(idx(), msg.userQuery || msg.content)}
|
|
style={{
|
|
display: "inline-flex",
|
|
"align-items": "center",
|
|
padding: "5px 12px",
|
|
background: "#FF5E13",
|
|
border: "none",
|
|
"border-radius": "20px",
|
|
color: "#fff",
|
|
"font-size": "11px",
|
|
"font-weight": "600",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Create Support Ticket
|
|
</button>
|
|
</Show>
|
|
|
|
{/* Save-profile button — for profile_draft messages */}
|
|
<Show when={msg.actionType === "profile_draft"}>
|
|
<button
|
|
onClick={() => confirmSaveProfile(idx(), msg.content)}
|
|
style={{
|
|
display: "inline-flex",
|
|
"align-items": "center",
|
|
padding: "5px 12px",
|
|
background: "#16A34A",
|
|
border: "none",
|
|
"border-radius": "20px",
|
|
color: "#fff",
|
|
"font-size": "11px",
|
|
"font-weight": "600",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Save to Profile
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</>
|
|
)}
|
|
</For>
|
|
<Show when={isLoading()}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
"align-items": "center",
|
|
gap: "8px",
|
|
color: "#9CA3AF",
|
|
"font-size": "13px",
|
|
}}
|
|
>
|
|
<Loader size={14} style={{ animation: "spin 1s linear infinite" }} />
|
|
Thinking...
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div
|
|
style={{
|
|
padding: "12px 16px",
|
|
"border-top": "1px solid #E5E7EB",
|
|
display: "flex",
|
|
gap: "8px",
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={input()}
|
|
onInput={(e) => setInput(e.currentTarget.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask me anything..."
|
|
aria-label="Chat message input"
|
|
style={{
|
|
flex: 1,
|
|
height: "40px",
|
|
"border-radius": "20px",
|
|
border: "1px solid #E5E7EB",
|
|
padding: "0 16px",
|
|
"font-size": "13px",
|
|
outline: "none",
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={sendMessage}
|
|
disabled={isLoading() || !input().trim()}
|
|
aria-label="Send message"
|
|
style={{
|
|
width: "40px",
|
|
height: "40px",
|
|
"border-radius": "50%",
|
|
background: isLoading() ? "#E5E7EB" : "#FF5E13",
|
|
border: "none",
|
|
cursor: isLoading() ? "default" : "pointer",
|
|
display: "flex",
|
|
"align-items": "center",
|
|
"justify-content": "center",
|
|
}}
|
|
>
|
|
<Send size={16} color="#fff" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
<style>{`
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
}
|