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) => (
-
+ )}
+
+
-
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) && (
-
- setPeoplePage((p) => p - 1)}>
- قبلی
-
- {peoplePage}
- = peopleTotal}
- onClick={() => setPeoplePage((p) => p + 1)}
+ )}
+ {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) && (
+
+ setPeoplePage((p) => p - 1)}>
+ قبلی
+
+ {peoplePage}
+ = peopleTotal}
+ onClick={() => setPeoplePage((p) => p + 1)}
+ >
+ بعدی
+
+
+ )}
+
+
+
);
}
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 ? (
-
-
- دوره را تمام کردهاید. گواهینامه دیجیتال آمادهٔ صدور است.
-
-
void claim()}>
- {issuing ? : }
- دریافت گواهینامه
-
+
+
+
+
در انتظار تأیید
+
+ درخواست گواهینامه ثبت شد و پس از بررسی توسط تیم آموزش صادر میشود.
+
+
) : (
(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 */}
-
setSettingsOpen((o) => !o)} className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-2 text-xs font-bold text-white transition hover:bg-white/20 min-h-[44px]" title="تنظیمات">
+ {/* Speed pill — click cycles through speeds */}
+
{speed}×
@@ -789,9 +822,16 @@ export function VideoPlayer({
{/* Watched pct */}
{Math.round(watchedPct)}٪
- {/* Settings gear */}
- setSettingsOpen((o) => !o)} className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center" title="تنظیمات (settings)">
-
+ {/* Settings gear — opens ambient / global settings only */}
+ setSettingsOpen((o) => !o)}
+ className="rounded-full bg-white/10 p-2.5 text-white transition hover:bg-white/20 min-w-[44px] min-h-[44px] flex items-center justify-center"
+ title="تنظیمات پخش"
+ aria-expanded={settingsOpen}
+ >
+
{/* 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) => (
changeSpeed(s)}
+ onClick={() => { changeSpeed(s); setSettingsOpen(false); }}
className={cn(
- "rounded-lg px-3 py-1.5 text-xs font-bold transition-colors min-h-[36px]",
+ "rounded-lg py-1.5 text-xs font-bold transition-colors",
speed === s
? "bg-primary text-primary-foreground"
: "bg-white/10 hover:bg-white/20",
@@ -836,17 +880,20 @@ export function VideoPlayer({
))}
+
+
+
{/* Ambient toggle */}
-
- حالت محیطی (Ambient)
+
+ حالت محیطی
{ambientMode ? "فعال" : "غیرفعال"}
@@ -873,7 +920,10 @@ export function VideoPlayer({
{/* ── Progress strip below player ─── */}
-