Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions frontend/app/(main)/admin/BestPracticesAnnouncementPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"use client";

import React, { useState } from "react";
import {
Activity,
CheckCircle2,
Trophy,
Heart,
Sparkles,
Star,
ExternalLink,
} from "lucide-react";
import { sendBestPracticesAnnouncement } from "@/lib/api";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";

export default function BestPracticesAnnouncementPanel() {
const [subject, setSubject] = useState("");
const [ctaUrl, setCtaUrl] = useState("");
const [testEmail, setTestEmail] = useState("");
const [sendToAll, setSendToAll] = useState(false);
const [sending, setSending] = useState(false);

const effectiveSubject =
subject.trim() || "Koder — Discover Best Practices: See how top developers solve problems";

const canSend = sendToAll || testEmail.trim().length > 0;

const handleSend = async () => {
if (!sendToAll && !testEmail.trim()) {
toast.error("Enter a test email or enable Send to all.");
return;
}

setSending(true);

const frontendBase = window.location.origin;
let finalCta = ctaUrl.trim();
if (!finalCta) {
finalCta = `${frontendBase}/home?bp_tab=all`;
} else if (finalCta.startsWith("/")) {
finalCta = `${frontendBase}${finalCta}`;
}

const payload = {
subject: effectiveSubject,
cta_url: finalCta,
test_email: testEmail.trim() || undefined,
send_to_all: sendToAll,
};

const res = await sendBestPracticesAnnouncement(payload);
setSending(false);

if (!res.success) {
toast.error(res.error?.message || "Failed to send announcement.");
return;
}

const attempted = res.data?.attempted ?? 0;
const sent = res.data?.sent ?? 0;
const failed = res.data?.failed ?? 0;

if (sendToAll) {
toast.success(
`Best Practices announcement queued to ${attempted} users; ${sent} sent, ${failed} failed.`,
);
} else {
toast.success(`Test email sent to ${testEmail.trim()}.`);
}
};

return (
<div className="bg-brand-charcoal-card border border-brand-charcoal-border rounded-2xl overflow-hidden">
<div className="px-5 py-4 border-b border-brand-charcoal-border flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brand-muted-gold/10 border border-brand-muted-gold/20 flex items-center justify-center">
<Trophy size={18} className="text-brand-muted-gold" />
</div>
<div>
<h3 className="font-bold text-brand-offwhite text-sm">
Best Practices Announcement
</h3>
<p className="text-[11px] text-brand-offwhite-muted mt-1">
Send an introductory email about the Best Practices feature with live stats and solution previews.
</p>
</div>
</div>

<div className="p-4 space-y-4">
<label className="space-y-2 text-sm text-brand-offwhite-muted">
Subject
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Koder — Discover Best Practices: See how top developers solve problems"
className="w-full bg-brand-charcoal-base border border-brand-charcoal-border rounded-xl px-3 py-2 text-sm text-brand-offwhite focus:outline-none focus:border-brand-muted-gold"
/>
</label>

<label className="space-y-2 text-sm text-brand-offwhite-muted">
CTA URL (optional)
<input
value={ctaUrl}
onChange={(event) => setCtaUrl(event.target.value)}
placeholder="Leave blank to use /home?bp_tab=all"
className="w-full bg-brand-charcoal-base border border-brand-charcoal-border rounded-xl px-3 py-2 text-sm text-brand-offwhite focus:outline-none focus:border-brand-muted-gold font-mono"
/>
</label>

<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<label className="space-y-2 text-sm text-brand-offwhite-muted">
Test email address
<input
value={testEmail}
onChange={(event) => setTestEmail(event.target.value)}
placeholder="admin@example.com"
className="w-full bg-brand-charcoal-base border border-brand-charcoal-border rounded-xl px-3 py-2 text-sm text-brand-offwhite focus:outline-none focus:border-brand-muted-gold"
/>
</label>
<label className="flex items-center gap-3 text-sm text-brand-offwhite-muted">
<input
type="checkbox"
checked={sendToAll}
onChange={(event) => setSendToAll(event.target.checked)}
className="h-4 w-4 rounded border-brand-charcoal-border bg-brand-charcoal-base text-brand-muted-gold focus:ring-brand-muted-gold"
/>
<span>Send to all users</span>
</label>
</div>

<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-brand-offwhite-muted">
{sendToAll
? "This email will be delivered to all registered users with an email address."
: "Enter a test email to send a single preview email."}
</div>
<button
onClick={handleSend}
disabled={!canSend || sending}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-2xl px-4 py-2.5 text-sm font-bold transition-all",
canSend
? "bg-brand-muted-gold text-brand-charcoal-base hover:brightness-110"
: "bg-brand-charcoal-border text-brand-offwhite-muted cursor-not-allowed",
)}
>
{sending ? (
<Activity className="animate-spin" size={14} />
) : (
<CheckCircle2 size={14} />
)}
{sending ? "Sending..." : "Send announcement"}
</button>
</div>

{/* Email preview */}
<div className="rounded-3xl border border-brand-charcoal-border bg-brand-charcoal-base p-4">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<p className="text-[11px] uppercase tracking-[0.24em] text-brand-offwhite-muted">
Email preview
</p>
<h4 className="mt-2 text-base font-bold text-brand-offwhite">
{effectiveSubject}
</h4>
</div>
<div className="rounded-2xl bg-brand-charcoal-border px-3 py-1 text-[11px] uppercase tracking-[0.16em] text-brand-offwhite-muted flex items-center gap-1.5">
<Trophy size={12} className="text-brand-muted-gold" />
Feature announcement
</div>
</div>

<div className="space-y-3">
{/* Stats preview */}
<div className="rounded-2xl border border-brand-charcoal-border/60 bg-brand-charcoal-card p-3">
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className="text-lg font-bold text-brand-offwhite">--</div>
<div className="text-[10px] text-brand-offwhite-muted uppercase">Solutions</div>
</div>
<div>
<div className="text-lg font-bold text-brand-offwhite">--</div>
<div className="text-[10px] text-brand-offwhite-muted uppercase">Developers</div>
</div>
<div>
<div className="text-lg font-bold text-brand-offwhite">--</div>
<div className="text-[10px] text-brand-offwhite-muted uppercase">Likes</div>
</div>
<div>
<div className="text-lg font-bold text-brand-offwhite">--</div>
<div className="text-[10px] text-brand-offwhite-muted uppercase">Go / Python</div>
</div>
</div>
</div>

{/* Feature cards preview */}
<div className="space-y-2">
{[
{ icon: Heart, label: "Community Solutions", color: "bg-amber-100" },
{ icon: Sparkles, label: "AI-Powered Code Analysis", color: "bg-purple-100" },
{ icon: Star, label: "How to Get Featured", color: "bg-yellow-100" },
].map(({ icon: Icon, label, color }) => (
<div
key={label}
className="flex items-center gap-3 rounded-2xl border border-brand-charcoal-border/60 bg-brand-charcoal-card px-3 py-2.5"
>
<div className={cn("w-8 h-8 rounded-lg flex items-center justify-center", color)}>
<Icon size={14} className="text-brand-muted-gold" />
</div>
<span className="text-xs font-bold text-brand-offwhite">{label}</span>
</div>
))}
</div>

<div className="flex items-center gap-2 text-[11px] text-brand-offwhite-muted">
<ExternalLink size={14} className="text-brand-muted-gold" />
<span>
{ctaUrl.trim() || "https://.../home?bp_tab=all"}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions frontend/app/(main)/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import PendingContributions from "./PendingContributions";
import FeedbackPanel from "./FeedbackPanel";
import BroadcastPanel from "./BroadcastPanel";
import EmailBroadcastPanel from "./EmailBroadcastPanel";
import BestPracticesAnnouncementPanel from "./BestPracticesAnnouncementPanel";
import ProblemEditPanel from "./ProblemEditPanel";
import ProblemReports from "./ProblemReports";
import UserVerificationPanel from "./UserVerificationPanel";
Expand Down Expand Up @@ -1293,6 +1294,13 @@ export default function AdminDashboard() {
</div>
</div>

{/* Best Practices Announcement */}
<div className="bg-brand-charcoal-card border border-brand-charcoal-border rounded-2xl overflow-hidden">
<div className="p-4">
<BestPracticesAnnouncementPanel />
</div>
</div>

{/* Broadcasts */}
<div className="bg-brand-charcoal-card border border-brand-charcoal-border rounded-2xl overflow-hidden">
<div className="px-5 py-4 border-b border-brand-charcoal-border flex items-center gap-2">
Expand Down
12 changes: 0 additions & 12 deletions frontend/app/(main)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
Trophy,
ArrowLeft,
BookOpen,
FlaskConical,
} from "lucide-react";
import { LanguageLogo } from "@/components/LanguageLogo";
import GoogleLinkBanner from "@/components/GoogleLinkBanner";
Expand Down Expand Up @@ -589,17 +588,6 @@ export default function Dashboard() {
</div>
)}
</>
) : user?.role !== "admin" ? (
<div>
<Card className="p-12 text-center border-dashed border-white/10 bg-card/50">
<FlaskConical className="mx-auto mb-4 text-amber-500/30" size={48} />
<h3 className="text-lg font-bold text-foreground mb-2">Best Practices — Coming Soon</h3>
<p className="text-muted-foreground max-w-md mx-auto">
This feature is in private beta and currently available to administrators only.
Stay tuned for the public release.
</p>
</Card>
</div>
) : (
<BestPracticesSection
solutions={bestPractices}
Expand Down
12 changes: 11 additions & 1 deletion frontend/components/best-practices/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export function ChatComposer({
el.style.height = `${Math.min(el.scrollHeight, 112)}px`;
}, [question]);

// Focus the textarea after the FollowUpDrawer's slide-in animation completes
// (280ms + 40ms buffer). Replaces autoFocus which fires while the element is
// still off-screen mid-transform — the browser considers the focus attempt
// "done" before the animation finishes, so subsequent clicks don't register.
useEffect(() => {
const timer = setTimeout(() => {
textareaRef.current?.focus({ preventScroll: true });
}, 320);
return () => clearTimeout(timer);
}, []);

const submit = () => {
if (!question.trim() || loading) return;
onSend();
Expand All @@ -39,7 +50,6 @@ export function ChatComposer({
<div className="flex items-end gap-2 rounded-xl bg-brand-charcoal-card px-3 py-2">
<textarea
ref={textareaRef}
autoFocus
value={question}
onChange={(e) => onQuestionChange(e.target.value)}
onKeyDown={(e) => {
Expand Down
15 changes: 15 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,21 @@ export async function sendProblemReminder(data: {
);
}

export async function sendBestPracticesAnnouncement(data: {
subject?: string;
cta_url?: string;
test_email?: string;
send_to_all?: boolean;
}): Promise<ApiResponse<{ attempted: number; sent: number; failed: number }>> {
return fetchApi<{ attempted: number; sent: number; failed: number }>(
"/admin/broadcast-best-practices",
{
method: "POST",
body: JSON.stringify(data),
},
);
}

export async function deactivateBroadcast(
id: string,
): Promise<ApiResponse<any>> {
Expand Down
Loading
Loading