diff --git a/backend/internal/api/certificate.go b/backend/internal/api/certificate.go index 16ee51d..dfae108 100644 --- a/backend/internal/api/certificate.go +++ b/backend/internal/api/certificate.go @@ -28,13 +28,13 @@ func (s *Server) certEnabled(ctx context.Context) bool { return true } -func (s *Server) certRequiresApproval(ctx context.Context) bool { - if v, err := s.store.GetSetting(ctx, "certificate_requires_approval"); err == nil { - return v == "true" || v == "1" - } - return true -} +// certRequiresApproval always returns true — certificates are never auto-issued. +// The DB setting is kept for observability but cannot override the hard gate. +func (s *Server) certRequiresApproval(_ context.Context) bool { return true } +// issueCertificateIfEligible creates a pending certificate row when the user has +// completed the curriculum. It never issues (publishes) the certificate directly; +// that step requires explicit admin action via handleAdminIssueCertificate. func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) { if !s.certEnabled(ctx) { return nil, false, nil @@ -53,22 +53,12 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us } else if err != store.ErrNotFound { return nil, false, err } - if s.certRequiresApproval(ctx) { - cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name) - if err != nil { - return nil, false, err - } - return cert, cert.Status == "pending", nil - } - publicID, err := newPublicID() + // Always create a pending row — never auto-issue. + cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name) if err != nil { return nil, false, err } - cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name) - if err != nil { - return nil, false, err - } - return cert, true, nil + return cert, cert.Status == "pending", nil } func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) { @@ -124,7 +114,7 @@ func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request) return } cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID) - if err != nil || cert.RevokedAt != nil { + if err != nil || cert.RevokedAt != nil || cert.Status != "issued" { writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد") return } diff --git a/backend/internal/api/quiz.go b/backend/internal/api/quiz.go index a2f1d53..50dac70 100644 --- a/backend/internal/api/quiz.go +++ b/backend/internal/api/quiz.go @@ -158,9 +158,10 @@ func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) { return } if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil { - observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID) - _ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_issued", - "گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil) + observability.Activity("certificate.pending", "user_id", u.ID) + _ = s.notify.Notify(r.Context(), u.ID, "progress", "certificate_pending", + "درخواست گواهینامه ثبت شد", "دوره را با موفقیت به پایان رساندید. درخواست گواهینامه شما ثبت شد و پس از تأیید مدیریت صادر خواهد شد.", "/profile", nil) + _ = cert // pending; no publicId yet } s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent) _ = s.notifyUnlockedLessons(u.ID, id) diff --git a/frontend/src/app/unwrap/page.tsx b/frontend/src/app/unwrap/page.tsx index 700a739..5d92a7c 100644 --- a/frontend/src/app/unwrap/page.tsx +++ b/frontend/src/app/unwrap/page.tsx @@ -152,165 +152,170 @@ function ChatsLauncher() { const convTotal = convData?.total ?? conversations.length; return ( -
- -
-
- -
-

- گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول می‌کشد. -

- +
+ {/* ── Centered header banner ── */} +
+
+ + یک پنجرهٔ شناور شیشه‌ای در تمام صفحات — با صدا، عکس، ویدیو و پیام صوتی.
- + +

+ گفتگو همیشه در دکمهٔ شناور پایین صفحه در دسترس شماست؛ گفتگوها خصوصی هستند و پاسخ منتورها معمولاً کمتر از یک روز طول می‌کشد. +

+
- -
- {convLoading && ( -
- {[0, 1, 2].map((i) => ( -
- -
-
- - + {/* ── Side-by-side panels ── */} +
+ {/* Conversations panel */} + +
+ {convLoading && ( +
+ {[0, 1, 2].map((i) => ( +
+ +
+
+ + +
+
-
-
- ))} -
- )} - {conversations.map((c) => ( - + ))} + {!convLoading && conversations.length === 0 && ( +

+ هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید. +

+ )} + {convTotal > (convData?.pageSize ?? 10) && ( +
+ + {convPage} +
- {c.unreadCount > 0 && ( - - {c.unreadCount} - - )} - - ))} - {!convLoading && conversations.length === 0 && ( -

- هنوز گفتگویی ندارید — از فهرست کنار، یک نفر را انتخاب کنید. -

- )} - {convTotal > (convData?.pageSize ?? 10) && ( -
- - {convPage} - -
- )} -
- + )} +
+ - setAlpha((v) => !v)}> - {alpha ? "آنلاین بالا" : "الفبایی"} - - } - > -
- {isLoading && ( -
- {[0, 1, 2, 3].map((i) => ( -
- -
- - + {/* Start chat panel */} + setAlpha((v) => !v)}> + {alpha ? "آنلاین بالا" : "الفبایی"} + + } + > +
+ {isLoading && ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ +
+ + +
+
- -
- ))} -
- )} - {people.map((m) => ( - openChat(m.id)} - className="flex w-full items-center gap-3 rounded-xl border-2 border-border/70 px-3 py-2.5 text-right transition-colors hover:border-primary/50 hover:bg-primary/5" - > - -
-

- {m.name} - {isVerifiedRole(m.role) && } -

-

{roleWord(m.role)}

+ ))}
- - - {m.online ? "آنلاین" : "آفلاین"} - -
- ))} - {!isLoading && mentors.length === 0 && ( -

{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}

- )} - {peopleTotal > (data?.pageSize ?? 20) && ( -
- - {peoplePage} - -
- )} -
- + +
+

+ {m.name} + {isVerifiedRole(m.role) && } +

+

{roleWord(m.role)}

+
+ + + {m.online ? "آنلاین" : "آفلاین"} + + + ))} + {!isLoading && mentors.length === 0 && ( +

{staff ? "کسی برای گفتگو نیست." : "هنوز منتوری در دسترس نیست."}

+ )} + {peopleTotal > (data?.pageSize ?? 20) && ( +
+ + {peoplePage} + +
+ )} +
+ +
); } diff --git a/frontend/src/components/profile-certificate-slot.tsx b/frontend/src/components/profile-certificate-slot.tsx index bf83430..9eaee13 100644 --- a/frontend/src/components/profile-certificate-slot.tsx +++ b/frontend/src/components/profile-certificate-slot.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useRouter } from "next/navigation"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { Award, Clock, Copy, ExternalLink, Loader2, Lock } from "lucide-react"; import { http, toUserError } from "@/lib/api"; import type { Certificate } from "@/lib/types"; @@ -17,9 +17,6 @@ type MineCertificateResponse = { export function ProfileCertificateSlot() { const router = useRouter(); - const qc = useQueryClient(); - const [issuing, setIssuing] = useState(false); - const autoIssueTried = useRef(false); const { data, isLoading, isError, error, refetch } = useQuery({ queryKey: ["me", "certificate"], @@ -29,18 +26,6 @@ export function ProfileCertificateSlot() { const cert = data?.certificate ?? null; const eligible = !!data?.eligible; - // Auto-issue once when the student becomes eligible (same path as /cap). - useEffect(() => { - if (!eligible || cert || autoIssueTried.current) return; - autoIssueTried.current = true; - setIssuing(true); - void http - .post<{ certificate: Certificate }>("/api/me/certificate/issue", {}) - .then(() => qc.invalidateQueries({ queryKey: ["me", "certificate"] })) - .catch((e) => toast.error(toUserError(e, "صدور خودکار گواهینامه ممکن نشد — دستی تلاش کنید"))) - .finally(() => setIssuing(false)); - }, [eligible, cert, qc]); - const copyLink = async () => { if (!cert?.publicId) return; const url = `${window.location.origin}/c/${cert.publicId}`; @@ -57,21 +42,6 @@ export function ProfileCertificateSlot() { router.push(`/c/${cert.publicId}`); }; - const claim = async () => { - setIssuing(true); - try { - const res = await http.post<{ certificate: Certificate }>("/api/me/certificate/issue", {}); - await qc.invalidateQueries({ queryKey: ["me", "certificate"] }); - if (res.certificate?.publicId) { - router.push(`/c/${res.certificate.publicId}`); - } - } catch (e) { - toast.error(toUserError(e, "صدور گواهینامه ممکن نشد")); - } finally { - setIssuing(false); - } - }; - return (
- ) : isLoading || (eligible && !cert && issuing) ? ( + ) : isLoading ? (
در حال بارگذاری… @@ -132,14 +102,14 @@ export function ProfileCertificateSlot() {
) : eligible ? ( -
-

- دوره را تمام کرده‌اید. گواهینامه دیجیتال آمادهٔ صدور است. -

- +
+ +
+

در انتظار تأیید

+

+ درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر می‌شود. +

+
) : (
(null); const hideTimer = useRef(null); + const settingsPanelRef = useRef(null); + const settingsBtnRef = useRef(null); // ── Utilities ─────────────────────────────────────────────────────────────── const warnSeekBlocked = useCallback(() => { @@ -161,7 +163,6 @@ export function VideoPlayer({ const poke = useCallback(() => { setUiIdle(false); - setSettingsOpen(false); if (hideTimer.current) window.clearTimeout(hideTimer.current); const v = videoRef.current; if (v && !v.paused && !v.ended) { @@ -202,6 +203,12 @@ export function VideoPlayer({ if (v) v.playbackRate = s; }, []); + const cycleSpeed = useCallback(() => { + const idx = ALLOWED_SPEEDS.indexOf(userSpeedRef.current); + const next = ALLOWED_SPEEDS[(idx + 1) % ALLOWED_SPEEDS.length]; + changeSpeed(next); + }, [changeSpeed]); + const toggleAmbient = useCallback(() => { setAmbientMode((prev) => { writeStorage("pargar:ambient", !prev); @@ -485,6 +492,19 @@ export function VideoPlayer({ return () => window.removeEventListener("keydown", onKey); }, [toggle, skipBack, warnSeekBlocked, poke, toggleFullscreen]); + // ── Settings click-outside close ──────────────────────────────────────────── + useEffect(() => { + if (!settingsOpen) return; + const onMouseDown = (e: MouseEvent) => { + const target = e.target as Node; + if (settingsPanelRef.current?.contains(target)) return; + if (settingsBtnRef.current?.contains(target)) return; + setSettingsOpen(false); + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [settingsOpen]); + // ── Cleanup timers ────────────────────────────────────────────────────────── useEffect(() => { return () => { @@ -588,13 +608,21 @@ export function VideoPlayer({ const VolumeIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; return ( -
+
{/* ── Player container ─── */}
{ const v = parseFloat(e.target.value); setVolume(v); if (v > 0 && muted) setMuted(false); }} className="h-1 w-16 cursor-pointer accent-white opacity-80 hover:opacity-100" aria-label="میزان صدا" />
- {/* Speed pill */} - @@ -789,9 +822,16 @@ export function VideoPlayer({ {/* Watched pct */} {Math.round(watchedPct)}٪ - {/* Settings gear */} - {/* Theater mode */} @@ -806,27 +846,31 @@ export function VideoPlayer({
- {/* Settings flyout */} + {/* Settings flyout — anchored above the controls bar, closes only on outside click */} {settingsOpen && ( e.stopPropagation()} > - {/* Playback speed */} -

سرعت پخش

-
+

تنظیمات پخش

+ + {/* Speed picker (full grid) */} +

سرعت

+
{ALLOWED_SPEEDS.map((s) => ( ))}
+ +
+ {/* Ambient toggle */}
{/* ── Progress strip below player ─── */} -
+

پیشرفت تماشای تاییدشده