Prettier format pending files
This commit is contained in:
parent
6a8e2f9180
commit
c37b8f48ad
6 changed files with 473 additions and 134 deletions
|
|
@ -144,8 +144,14 @@ const App = () => (
|
|||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/:slug" element={<BlogPost />} />
|
||||
<Route path="/community" element={<Community />} />
|
||||
<Route path="/community/mentorship" element={<MentorshipRequest />} />
|
||||
<Route path="/community/mentorship/apply" element={<MentorApply />} />
|
||||
<Route
|
||||
path="/community/mentorship"
|
||||
element={<MentorshipRequest />}
|
||||
/>
|
||||
<Route
|
||||
path="/community/mentorship/apply"
|
||||
element={<MentorApply />}
|
||||
/>
|
||||
<Route path="/community/:tabId" element={<Community />} />
|
||||
<Route path="/staff" element={<Staff />} />
|
||||
<Route path="/support" element={<Support />} />
|
||||
|
|
|
|||
|
|
@ -177,20 +177,34 @@ export const aethexSocialService = {
|
|||
},
|
||||
|
||||
// Mentorship
|
||||
async listMentors(params?: { expertise?: string[]; q?: string; available?: boolean; limit?: number }) {
|
||||
async listMentors(params?: {
|
||||
expertise?: string[];
|
||||
q?: string;
|
||||
available?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.expertise?.length) qs.set("expertise", params.expertise.join(","));
|
||||
if (params?.expertise?.length)
|
||||
qs.set("expertise", params.expertise.join(","));
|
||||
if (params?.q) qs.set("q", params.q);
|
||||
if (typeof params?.available === "boolean") qs.set("available", String(params.available));
|
||||
if (typeof params?.available === "boolean")
|
||||
qs.set("available", String(params.available));
|
||||
if (params?.limit) qs.set("limit", String(params.limit));
|
||||
const resp = await fetch(`/api/mentors${qs.toString() ? `?${qs.toString()}` : ""}`);
|
||||
const resp = await fetch(
|
||||
`/api/mentors${qs.toString() ? `?${qs.toString()}` : ""}`,
|
||||
);
|
||||
if (!resp.ok) return [] as any[];
|
||||
return (await resp.json()) as any[];
|
||||
},
|
||||
|
||||
async applyToBeMentor(
|
||||
userId: string,
|
||||
input: { bio?: string | null; expertise: string[]; hourlyRate?: number | null; available?: boolean },
|
||||
input: {
|
||||
bio?: string | null;
|
||||
expertise: string[];
|
||||
hourlyRate?: number | null;
|
||||
available?: boolean;
|
||||
},
|
||||
) {
|
||||
const resp = await fetch("/api/mentors/apply", {
|
||||
method: "POST",
|
||||
|
|
@ -199,19 +213,29 @@ export const aethexSocialService = {
|
|||
user_id: userId,
|
||||
bio: input.bio ?? null,
|
||||
expertise: input.expertise || [],
|
||||
hourly_rate: typeof input.hourlyRate === "number" ? input.hourlyRate : null,
|
||||
available: typeof input.available === "boolean" ? input.available : true,
|
||||
hourly_rate:
|
||||
typeof input.hourlyRate === "number" ? input.hourlyRate : null,
|
||||
available:
|
||||
typeof input.available === "boolean" ? input.available : true,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
return await resp.json();
|
||||
},
|
||||
|
||||
async requestMentorship(menteeId: string, mentorId: string, message?: string) {
|
||||
async requestMentorship(
|
||||
menteeId: string,
|
||||
mentorId: string,
|
||||
message?: string,
|
||||
) {
|
||||
const resp = await fetch("/api/mentorship/request", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mentee_id: menteeId, mentor_id: mentorId, message: message || null }),
|
||||
body: JSON.stringify({
|
||||
mentee_id: menteeId,
|
||||
mentor_id: mentorId,
|
||||
message: message || null,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
return await resp.json();
|
||||
|
|
@ -230,11 +254,14 @@ export const aethexSocialService = {
|
|||
actorId: string,
|
||||
status: "accepted" | "rejected" | "cancelled",
|
||||
) {
|
||||
const resp = await fetch(`/api/mentorship/requests/${encodeURIComponent(id)}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actor_id: actorId, status }),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`/api/mentorship/requests/${encodeURIComponent(id)}/status`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ actor_id: actorId, status }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
return await resp.json();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { aethexToast } from "@/lib/aethex-toast";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
|
@ -29,12 +35,18 @@ export default function Staff() {
|
|||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
aethexToast.info({ title: "Sign in required", description: "Staff area requires authentication" });
|
||||
aethexToast.info({
|
||||
title: "Sign in required",
|
||||
description: "Staff area requires authentication",
|
||||
});
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
if (!hasAccess) {
|
||||
aethexToast.error({ title: "Access denied", description: "You don't have staff permissions" });
|
||||
aethexToast.error({
|
||||
title: "Access denied",
|
||||
description: "You don't have staff permissions",
|
||||
});
|
||||
navigate("/dashboard");
|
||||
}
|
||||
}, [user, roles, hasAccess, loading, navigate]);
|
||||
|
|
@ -66,15 +78,24 @@ export default function Staff() {
|
|||
if (user && hasAccess) refresh();
|
||||
}, [user, hasAccess]);
|
||||
|
||||
const updateReportStatus = async (id: string, status: "resolved" | "ignored" | "open") => {
|
||||
const updateReportStatus = async (
|
||||
id: string,
|
||||
status: "resolved" | "ignored" | "open",
|
||||
) => {
|
||||
try {
|
||||
const resp = await fetch(`/api/moderation/reports/${encodeURIComponent(id)}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
const resp = await fetch(
|
||||
`/api/moderation/reports/${encodeURIComponent(id)}/status`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
aethexToast.success({ title: "Updated", description: `Report marked ${status}` });
|
||||
aethexToast.success({
|
||||
title: "Updated",
|
||||
description: `Report marked ${status}`,
|
||||
});
|
||||
refresh();
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -84,9 +105,13 @@ export default function Staff() {
|
|||
<Layout>
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<div className="mb-6">
|
||||
<Badge variant="outline" className="mb-2">Internal</Badge>
|
||||
<Badge variant="outline" className="mb-2">
|
||||
Internal
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold">Operations Command</h1>
|
||||
<p className="text-muted-foreground">Staff dashboards, moderation, and internal tools.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Staff dashboards, moderation, and internal tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
|
|
@ -102,16 +127,26 @@ export default function Staff() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Community Health</CardTitle>
|
||||
<CardDescription>Quick pulse across posts and reports</CardDescription>
|
||||
<CardDescription>
|
||||
Quick pulse across posts and reports
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">Open reports</div>
|
||||
<div className="text-xl font-semibold">{openReports.length}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Open reports
|
||||
</div>
|
||||
<div className="text-xl font-semibold">
|
||||
{openReports.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="text-sm text-muted-foreground">Mentorship requests</div>
|
||||
<div className="text-xl font-semibold">{mentorshipAll.length}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Mentorship requests
|
||||
</div>
|
||||
<div className="text-xl font-semibold">
|
||||
{mentorshipAll.length}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -122,11 +157,15 @@ export default function Staff() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">Admin API</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Admin API
|
||||
</div>
|
||||
<Badge className="bg-emerald-600">OK</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className="text-sm text-muted-foreground">Notifications</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Notifications
|
||||
</div>
|
||||
<Badge className="bg-emerald-600">OK</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -137,9 +176,15 @@ export default function Staff() {
|
|||
<CardDescription>Common operational links</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button asChild variant="outline" className="w-full"><a href="/admin">Admin panel</a></Button>
|
||||
<Button asChild variant="outline" className="w-full"><a href="/community#mentorship">Mentorship hub</a></Button>
|
||||
<Button asChild variant="outline" className="w-full"><a href="/feed">Community feed</a></Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="/admin">Admin panel</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="/community#mentorship">Mentorship hub</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href="/feed">Community feed</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -156,19 +201,37 @@ export default function Staff() {
|
|||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
{!loadingData && openReports.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No items in queue.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No items in queue.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{openReports.map((r) => (
|
||||
<div key={r.id} className="rounded border border-border/50 p-3">
|
||||
<div
|
||||
key={r.id}
|
||||
className="rounded border border-border/50 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{r.reason}</div>
|
||||
<div className="text-xs text-muted-foreground">{r.details}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{r.details}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => updateReportStatus(r.id, "ignored")}>Ignore</Button>
|
||||
<Button size="sm" onClick={() => updateReportStatus(r.id, "resolved")}>Resolve</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => updateReportStatus(r.id, "ignored")}
|
||||
>
|
||||
Ignore
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateReportStatus(r.id, "resolved")}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -182,25 +245,42 @@ export default function Staff() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mentorship Requests</CardTitle>
|
||||
<CardDescription>Review recent mentor/mentee activity</CardDescription>
|
||||
<CardDescription>
|
||||
Review recent mentor/mentee activity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingData && (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
{!loadingData && mentorshipAll.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No requests to review.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No requests to review.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{mentorshipAll.map((req) => (
|
||||
<div key={req.id} className="rounded border border-border/50 p-3">
|
||||
<div
|
||||
key={req.id}
|
||||
className="rounded border border-border/50 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{req.mentee?.full_name || req.mentee?.username} → {req.mentor?.full_name || req.mentor?.username}</div>
|
||||
<div className="text-xs text-muted-foreground">{req.message || "No message"}</div>
|
||||
<div className="text-sm font-medium">
|
||||
{req.mentee?.full_name || req.mentee?.username} →{" "}
|
||||
{req.mentor?.full_name || req.mentor?.username}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.message || "No message"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="text-xs capitalize">{req.status}</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs capitalize"
|
||||
>
|
||||
{req.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -208,8 +288,12 @@ export default function Staff() {
|
|||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="flex gap-2">
|
||||
<Button asChild><a href="/community/mentorship">Open requests</a></Button>
|
||||
<Button asChild variant="outline"><a href="/community/mentorship/apply">Mentor directory</a></Button>
|
||||
<Button asChild>
|
||||
<a href="/community/mentorship">Open requests</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a href="/community/mentorship/apply">Mentor directory</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -219,10 +303,14 @@ export default function Staff() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CardDescription>Search, roles, and quick actions</CardDescription>
|
||||
<CardDescription>
|
||||
Search, roles, and quick actions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">User tools coming soon.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
User tools coming soon.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,13 @@ import { aethexToast } from "@/lib/aethex-toast";
|
|||
import { aethexSocialService } from "@/lib/aethex-social-service";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -24,7 +30,10 @@ export default function MentorApply() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
aethexToast.info({ title: "Sign in required", description: "Please sign in to apply as a mentor" });
|
||||
aethexToast.info({
|
||||
title: "Sign in required",
|
||||
description: "Please sign in to apply as a mentor",
|
||||
});
|
||||
navigate("/login");
|
||||
}
|
||||
}, [user, loading, navigate]);
|
||||
|
|
@ -35,14 +44,15 @@ export default function MentorApply() {
|
|||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
const next = Array.from(new Set([...expertise, ...parts]))
|
||||
.slice(0, 20);
|
||||
const next = Array.from(new Set([...expertise, ...parts])).slice(0, 20);
|
||||
setExpertise(next);
|
||||
setExpertiseInput("");
|
||||
};
|
||||
|
||||
const removeExpertise = (tag: string) => {
|
||||
setExpertise((prev) => prev.filter((t) => t.toLowerCase() !== tag.toLowerCase()));
|
||||
setExpertise((prev) =>
|
||||
prev.filter((t) => t.toLowerCase() !== tag.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
|
|
@ -58,13 +68,21 @@ export default function MentorApply() {
|
|||
await aethexSocialService.applyToBeMentor(user.id, {
|
||||
bio: bio || null,
|
||||
expertise,
|
||||
hourlyRate: Number.isFinite(rate as number) ? (rate as number) : undefined,
|
||||
hourlyRate: Number.isFinite(rate as number)
|
||||
? (rate as number)
|
||||
: undefined,
|
||||
available,
|
||||
});
|
||||
aethexToast.success({ title: "Mentor profile saved", description: "You're ready to receive mentorship requests" });
|
||||
aethexToast.success({
|
||||
title: "Mentor profile saved",
|
||||
description: "You're ready to receive mentorship requests",
|
||||
});
|
||||
navigate("/community#mentorship");
|
||||
} catch (e: any) {
|
||||
aethexToast.error({ title: "Failed to save", description: String(e?.message || e) });
|
||||
aethexToast.error({
|
||||
title: "Failed to save",
|
||||
description: String(e?.message || e),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
|
@ -74,33 +92,68 @@ export default function MentorApply() {
|
|||
<Layout>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-2">Mentorship</Badge>
|
||||
<Badge variant="outline" className="mb-2">
|
||||
Mentorship
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold">Become a mentor</h1>
|
||||
<p className="text-muted-foreground mt-1">Share your expertise and guide community members through 1:1 sessions and clinics.</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Share your expertise and guide community members through 1:1
|
||||
sessions and clinics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Mentor profile</CardTitle>
|
||||
<CardDescription>Tell mentees how you can help and set your availability.</CardDescription>
|
||||
<CardDescription>
|
||||
Tell mentees how you can help and set your availability.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Short bio</Label>
|
||||
<Textarea id="bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="What topics do you mentor on?" rows={5} />
|
||||
<Textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder="What topics do you mentor on?"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expertise">Expertise</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="expertise" value={expertiseInput} onChange={(e) => setExpertiseInput(e.target.value)} placeholder="Add tags, e.g. Unreal, AI, Networking" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addExpertise(); } }} />
|
||||
<Button type="button" onClick={addExpertise} variant="secondary">Add</Button>
|
||||
<Input
|
||||
id="expertise"
|
||||
value={expertiseInput}
|
||||
onChange={(e) => setExpertiseInput(e.target.value)}
|
||||
placeholder="Add tags, e.g. Unreal, AI, Networking"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addExpertise();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addExpertise}
|
||||
variant="secondary"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{expertise.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{expertise.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="cursor-pointer" onClick={() => removeExpertise(tag)}>
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => removeExpertise(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -111,14 +164,30 @@ export default function MentorApply() {
|
|||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rate">Hourly rate (optional)</Label>
|
||||
<Input id="rate" type="number" min="0" step="1" placeholder="e.g. 100" value={hourlyRate} onChange={(e) => setHourlyRate(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">Set to 0 or leave blank if you mentor for free.</p>
|
||||
<Input
|
||||
id="rate"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="e.g. 100"
|
||||
value={hourlyRate}
|
||||
onChange={(e) => setHourlyRate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set to 0 or leave blank if you mentor for free.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="available">Available</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="available" checked={available} onCheckedChange={setAvailable} />
|
||||
<span className="text-sm text-muted-foreground">Show in mentor directory</span>
|
||||
<Switch
|
||||
id="available"
|
||||
checked={available}
|
||||
onCheckedChange={setAvailable}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Show in mentor directory
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -127,7 +196,13 @@ export default function MentorApply() {
|
|||
<Button type="submit" disabled={!canSubmit}>
|
||||
{submitting ? "Saving..." : "Save mentor profile"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => navigate("/community#mentorship")}>Cancel</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/community#mentorship")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,23 @@ import { aethexToast } from "@/lib/aethex-toast";
|
|||
import { aethexSocialService } from "@/lib/aethex-social-service";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface MentorRow {
|
||||
|
|
@ -18,7 +30,13 @@ interface MentorRow {
|
|||
expertise: string[] | null;
|
||||
available: boolean;
|
||||
hourly_rate: number | null;
|
||||
user_profiles?: { id: string; full_name: string | null; username: string | null; avatar_url: string | null; bio: string | null } | null;
|
||||
user_profiles?: {
|
||||
id: string;
|
||||
full_name: string | null;
|
||||
username: string | null;
|
||||
avatar_url: string | null;
|
||||
bio: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function MentorshipRequest() {
|
||||
|
|
@ -37,7 +55,10 @@ export default function MentorshipRequest() {
|
|||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
aethexToast.info({ title: "Sign in required", description: "Please sign in to request mentorship" });
|
||||
aethexToast.info({
|
||||
title: "Sign in required",
|
||||
description: "Please sign in to request mentorship",
|
||||
});
|
||||
navigate("/login");
|
||||
}
|
||||
}, [user, loading, navigate]);
|
||||
|
|
@ -45,10 +66,18 @@ export default function MentorshipRequest() {
|
|||
const loadMentors = async () => {
|
||||
setLoadingMentors(true);
|
||||
try {
|
||||
const rows = await aethexSocialService.listMentors({ q: query || undefined, expertise: expertise.length ? expertise : undefined, available: true, limit: 30 });
|
||||
const rows = await aethexSocialService.listMentors({
|
||||
q: query || undefined,
|
||||
expertise: expertise.length ? expertise : undefined,
|
||||
available: true,
|
||||
limit: 30,
|
||||
});
|
||||
setMentors(rows as MentorRow[]);
|
||||
} catch (e: any) {
|
||||
aethexToast.error({ title: "Failed to load mentors", description: String(e?.message || e) });
|
||||
aethexToast.error({
|
||||
title: "Failed to load mentors",
|
||||
description: String(e?.message || e),
|
||||
});
|
||||
} finally {
|
||||
setLoadingMentors(false);
|
||||
}
|
||||
|
|
@ -65,14 +94,15 @@ export default function MentorshipRequest() {
|
|||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!parts.length) return;
|
||||
const next = Array.from(new Set([...expertise, ...parts]))
|
||||
.slice(0, 20);
|
||||
const next = Array.from(new Set([...expertise, ...parts])).slice(0, 20);
|
||||
setExpertise(next);
|
||||
setExpertiseInput("");
|
||||
};
|
||||
|
||||
const removeExpertise = (tag: string) => {
|
||||
setExpertise((prev) => prev.filter((t) => t.toLowerCase() !== tag.toLowerCase()));
|
||||
setExpertise((prev) =>
|
||||
prev.filter((t) => t.toLowerCase() !== tag.toLowerCase()),
|
||||
);
|
||||
};
|
||||
|
||||
const onOpenRequest = (m: MentorRow) => {
|
||||
|
|
@ -85,51 +115,100 @@ export default function MentorshipRequest() {
|
|||
if (!user?.id || !selectedMentor) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await aethexSocialService.requestMentorship(user.id, selectedMentor.user_id, message || undefined);
|
||||
aethexToast.success({ title: "Request sent", description: "The mentor has been notified" });
|
||||
await aethexSocialService.requestMentorship(
|
||||
user.id,
|
||||
selectedMentor.user_id,
|
||||
message || undefined,
|
||||
);
|
||||
aethexToast.success({
|
||||
title: "Request sent",
|
||||
description: "The mentor has been notified",
|
||||
});
|
||||
setDialogOpen(false);
|
||||
} catch (e: any) {
|
||||
aethexToast.error({ title: "Failed to send", description: String(e?.message || e) });
|
||||
aethexToast.error({
|
||||
title: "Failed to send",
|
||||
description: String(e?.message || e),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtersActive = useMemo(() => query.trim().length > 0 || expertise.length > 0, [query, expertise]);
|
||||
const filtersActive = useMemo(
|
||||
() => query.trim().length > 0 || expertise.length > 0,
|
||||
[query, expertise],
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-2">Mentorship</Badge>
|
||||
<Badge variant="outline" className="mb-2">
|
||||
Mentorship
|
||||
</Badge>
|
||||
<h1 className="text-3xl font-bold">Request mentorship</h1>
|
||||
<p className="text-muted-foreground mt-1">Find mentors by skill and send a short request. You’ll be notified when they respond.</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Find mentors by skill and send a short request. You’ll be notified
|
||||
when they respond.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>Refine mentors by topic or keyword.</CardDescription>
|
||||
<CardDescription>
|
||||
Refine mentors by topic or keyword.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="q">Search</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="q" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Name, username, bio" />
|
||||
<Button variant="secondary" onClick={loadMentors}>Search</Button>
|
||||
<Input
|
||||
id="q"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Name, username, bio"
|
||||
/>
|
||||
<Button variant="secondary" onClick={loadMentors}>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expertise">Expertise</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="expertise" value={expertiseInput} onChange={(e) => setExpertiseInput(e.target.value)} placeholder="Add tags, e.g. Unreal, AI, Networking" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addExpertise(); } }} />
|
||||
<Button type="button" variant="secondary" onClick={addExpertise}>Add</Button>
|
||||
<Input
|
||||
id="expertise"
|
||||
value={expertiseInput}
|
||||
onChange={(e) => setExpertiseInput(e.target.value)}
|
||||
placeholder="Add tags, e.g. Unreal, AI, Networking"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addExpertise();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={addExpertise}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{expertise.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{expertise.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="cursor-pointer" onClick={() => removeExpertise(tag)}>
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
onClick={() => removeExpertise(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
|
|
@ -139,7 +218,17 @@ export default function MentorshipRequest() {
|
|||
</div>
|
||||
{filtersActive && (
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button variant="outline" onClick={() => { setQuery(""); setExpertise([]); setExpertiseInput(""); loadMentors(); }}>Reset</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setExpertise([]);
|
||||
setExpertiseInput("");
|
||||
loadMentors();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={loadMentors}>Apply filters</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -148,32 +237,49 @@ export default function MentorshipRequest() {
|
|||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{loadingMentors && (
|
||||
<Card><CardContent className="p-6 text-sm text-muted-foreground">Loading mentors...</CardContent></Card>
|
||||
)}
|
||||
{!loadingMentors && mentors.length === 0 && (
|
||||
<Card><CardContent className="p-6 text-sm text-muted-foreground">No mentors found. Try adjusting filters.</CardContent></Card>
|
||||
)}
|
||||
{!loadingMentors && mentors.map((m) => (
|
||||
<Card key={m.user_id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
{m.user_profiles?.full_name || m.user_profiles?.username || "Mentor"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{(m.expertise || []).slice(0, 5).join(", ")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-3">
|
||||
{m.bio && <p className="text-sm text-muted-foreground line-clamp-3">{m.bio}</p>}
|
||||
{typeof m.hourly_rate === "number" && (
|
||||
<p className="text-sm">Rate: ${m.hourly_rate}/hr</p>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<Button onClick={() => onOpenRequest(m)} className="w-full">Request mentorship</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
Loading mentors...
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
{!loadingMentors && mentors.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
No mentors found. Try adjusting filters.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{!loadingMentors &&
|
||||
mentors.map((m) => (
|
||||
<Card key={m.user_id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">
|
||||
{m.user_profiles?.full_name ||
|
||||
m.user_profiles?.username ||
|
||||
"Mentor"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{(m.expertise || []).slice(0, 5).join(", ")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-3">
|
||||
{m.bio && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{m.bio}
|
||||
</p>
|
||||
)}
|
||||
{typeof m.hourly_rate === "number" && (
|
||||
<p className="text-sm">Rate: ${m.hourly_rate}/hr</p>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<Button onClick={() => onOpenRequest(m)} className="w-full">
|
||||
Request mentorship
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
|
@ -183,11 +289,21 @@ export default function MentorshipRequest() {
|
|||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="msg">Message</Label>
|
||||
<Textarea id="msg" value={message} onChange={(e) => setMessage(e.target.value)} rows={5} placeholder="Tell the mentor what you want to achieve" />
|
||||
<Textarea
|
||||
id="msg"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="Tell the mentor what you want to achieve"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={onSubmitRequest} disabled={submitting}>{submitting ? "Sending..." : "Send request"}</Button>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSubmitRequest} disabled={submitting}>
|
||||
{submitting ? "Sending..." : "Send request"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1183,9 +1183,12 @@ export function createServer() {
|
|||
// Mentorship API
|
||||
app.get("/api/mentors", async (req, res) => {
|
||||
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 20));
|
||||
const available = String(req.query.available || "true").toLowerCase() !== "false";
|
||||
const available =
|
||||
String(req.query.available || "true").toLowerCase() !== "false";
|
||||
const expertise = String(req.query.expertise || "").trim();
|
||||
const q = String(req.query.q || "").trim().toLowerCase();
|
||||
const q = String(req.query.q || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
try {
|
||||
const { data, error } = await adminSupabase
|
||||
.from("mentors")
|
||||
|
|
@ -1206,15 +1209,21 @@ export function createServer() {
|
|||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (terms.length) {
|
||||
rows = rows.filter((r: any) =>
|
||||
Array.isArray(r.expertise) && r.expertise.some((e: string) => terms.includes(String(e).toLowerCase())),
|
||||
rows = rows.filter(
|
||||
(r: any) =>
|
||||
Array.isArray(r.expertise) &&
|
||||
r.expertise.some((e: string) =>
|
||||
terms.includes(String(e).toLowerCase()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (q) {
|
||||
rows = rows.filter((r: any) => {
|
||||
const up = (r as any).user_profiles || {};
|
||||
const name = String(up.full_name || up.username || "").toLowerCase();
|
||||
const name = String(
|
||||
up.full_name || up.username || "",
|
||||
).toLowerCase();
|
||||
const bio = String(r.bio || up.bio || "").toLowerCase();
|
||||
return name.includes(q) || bio.includes(q);
|
||||
});
|
||||
|
|
@ -1226,7 +1235,8 @@ export function createServer() {
|
|||
});
|
||||
|
||||
app.post("/api/mentors/apply", async (req, res) => {
|
||||
const { user_id, bio, expertise, hourly_rate, available } = (req.body || {}) as {
|
||||
const { user_id, bio, expertise, hourly_rate, available } = (req.body ||
|
||||
{}) as {
|
||||
user_id?: string;
|
||||
bio?: string | null;
|
||||
expertise?: string[];
|
||||
|
|
@ -1271,7 +1281,9 @@ export function createServer() {
|
|||
message?: string | null;
|
||||
};
|
||||
if (!mentee_id || !mentor_id) {
|
||||
return res.status(400).json({ error: "mentee_id and mentor_id required" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "mentee_id and mentor_id required" });
|
||||
}
|
||||
if (mentee_id === mentor_id) {
|
||||
return res.status(400).json({ error: "cannot request yourself" });
|
||||
|
|
@ -1290,7 +1302,10 @@ export function createServer() {
|
|||
.select("full_name, username")
|
||||
.eq("id", mentee_id)
|
||||
.maybeSingle();
|
||||
const menteeName = (mentee as any)?.full_name || (mentee as any)?.username || "Someone";
|
||||
const menteeName =
|
||||
(mentee as any)?.full_name ||
|
||||
(mentee as any)?.username ||
|
||||
"Someone";
|
||||
await adminSupabase.from("notifications").insert({
|
||||
user_id: mentor_id,
|
||||
type: "info",
|
||||
|
|
@ -1345,7 +1360,10 @@ export function createServer() {
|
|||
if (upErr) return res.status(500).json({ error: upErr.message });
|
||||
|
||||
try {
|
||||
const target = status === "cancelled" ? (reqRow as any).mentor_id : (reqRow as any).mentee_id;
|
||||
const target =
|
||||
status === "cancelled"
|
||||
? (reqRow as any).mentor_id
|
||||
: (reqRow as any).mentee_id;
|
||||
const title =
|
||||
status === "accepted"
|
||||
? "Mentorship accepted"
|
||||
|
|
@ -1414,14 +1432,23 @@ export function createServer() {
|
|||
|
||||
// Moderation API
|
||||
app.post("/api/moderation/reports", async (req, res) => {
|
||||
const { reporter_id, target_type, target_id, reason, details } = (req.body || {}) as any;
|
||||
const { reporter_id, target_type, target_id, reason, details } =
|
||||
(req.body || {}) as any;
|
||||
if (!target_type || !reason) {
|
||||
return res.status(400).json({ error: "target_type and reason required" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "target_type and reason required" });
|
||||
}
|
||||
try {
|
||||
const { data, error } = await adminSupabase
|
||||
.from("moderation_reports")
|
||||
.insert({ reporter_id: reporter_id || null, target_type, target_id: target_id || null, reason, details: details || null } as any)
|
||||
.insert({
|
||||
reporter_id: reporter_id || null,
|
||||
target_type,
|
||||
target_id: target_id || null,
|
||||
reason,
|
||||
details: details || null,
|
||||
} as any)
|
||||
.select()
|
||||
.single();
|
||||
if (error) return res.status(500).json({ error: error.message });
|
||||
|
|
|
|||
Loading…
Reference in a new issue