From 03610bc3d353453dbed543fa8a3e8c3639e2d857 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Sun, 18 Oct 2026 10:14:22 +0330 Subject: [PATCH] feat: implement 8 features across full stack - Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze), deterministic tie-breaking (xp DESC, user_id ASC) - Mentor chat: md: breakpoints, safe-area-inset-bottom padding - Learning progress: server-side progressPct, curriculum expansion safety - Physical orders: hard-disabled with 410 Gone - Certificate approval: pending/issued flow, admin issue/revoke endpoints, certificate_requires_approval setting (migration 0031) - Draft/publish: is_published on lessons and chapters, admin publish UI (migration 0030) - Video panel: static thumbnails (no preload), client-side pagination - Rich editor: TipTap + tiptap-markdown for lesson descriptions, marked + DOMPurify for SafeMarkdown rendering --- backend/internal/api/admin.go | 32 + backend/internal/api/certificate.go | 159 ++-- backend/internal/api/leaderboard.go | 78 +- backend/internal/api/leaderboard_test.go | 31 + backend/internal/api/router.go | 5 + backend/internal/api/tree.go | 24 +- .../migrations/0030_lesson_draft_status.sql | 9 + .../migrations/0031_certificate_approval.sql | 13 + backend/internal/models/models.go | 5 +- backend/internal/store/store.go | 133 ++- frontend/package-lock.json | 840 +++++++++++++++++- frontend/package.json | 7 + frontend/src/app/c/[id]/page.tsx | 162 +--- frontend/src/app/cap/page.tsx | 68 +- frontend/src/components/admin-studio.tsx | 59 +- .../src/components/admin/learning-panel.tsx | 34 + .../src/components/admin/videos-panel.tsx | 93 +- frontend/src/components/leaderboard-panel.tsx | 28 +- frontend/src/components/mentor-chat.tsx | 4 +- .../components/profile-certificate-slot.tsx | 45 +- frontend/src/components/safe-markdown.tsx | 90 +- frontend/src/components/ui/rich-editor.tsx | 182 ++++ frontend/src/lib/types.ts | 14 +- 23 files changed, 1641 insertions(+), 474 deletions(-) create mode 100644 backend/internal/api/leaderboard_test.go create mode 100644 backend/internal/db/migrations/0030_lesson_draft_status.sql create mode 100644 backend/internal/db/migrations/0031_certificate_approval.sql create mode 100644 frontend/src/components/ui/rich-editor.tsx diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index ed5f9ee..139ad77 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -575,6 +575,38 @@ func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (s *Server) handleAdminPublishLesson(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var body struct { + Publish bool `json:"publish"` + } + if err := bodyJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if err := s.store.PublishLesson(r.Context(), id, body.Publish); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت درس ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminPublishChapter(w http.ResponseWriter, r *http.Request) { + id := routeID(r, "id") + var body struct { + Publish bool `json:"publish"` + } + if err := bodyJSON(r, &body); err != nil { + writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") + return + } + if err := s.store.PublishChapter(r.Context(), id, body.Publish); err != nil { + writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت فصل ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // ---- questions ---- func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/certificate.go b/backend/internal/api/certificate.go index 7c5bd33..16ee51d 100644 --- a/backend/internal/api/certificate.go +++ b/backend/internal/api/certificate.go @@ -7,7 +7,6 @@ import ( "net/http" "strconv" "strings" - "time" "pargar/backend/internal/models" "pargar/backend/internal/observability" @@ -22,7 +21,24 @@ func newPublicID() (string, error) { return hex.EncodeToString(b), nil } +func (s *Server) certEnabled(ctx context.Context) bool { + if v, err := s.store.GetSetting(ctx, "certificate_enabled"); err == nil { + return v == "true" || v == "1" + } + 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 +} + func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) { + if !s.certEnabled(ctx) { + return nil, false, nil + } ok, err := s.store.HasCompletedCurriculum(ctx, user.ID) if err != nil { return nil, false, err @@ -37,6 +53,13 @@ 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() if err != nil { return nil, false, err @@ -140,90 +163,66 @@ func (s *Server) physicalSettings(ctx context.Context) map[string]any { } func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) { - u := currentUser(r) - settings := s.physicalSettings(r.Context()) - if enabled, _ := settings["enabled"].(bool); !enabled { - writeErr(w, http.StatusBadRequest, "درخواست نسخه فیزیکی فعلاً فعال نیست") - return - } - cert, err := s.store.GetCertificateByUser(r.Context(), u.ID) - if err != nil || cert.RevokedAt != nil { - writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید") - return - } - days, _ := settings["windowDays"].(int) - if days <= 0 { - days = 30 - } - windowEnds := cert.IssuedAt.Add(time.Duration(days) * 24 * time.Hour) - if time.Now().After(windowEnds) { - writeErr(w, http.StatusConflict, "مهلت درخواست نسخه فیزیکی به پایان رسیده است") - return - } - if existing, err := s.store.GetLatestPhysicalOrder(r.Context(), u.ID); err == nil { - if existing.Status != models.PhysicalCancelled { - writeErr(w, http.StatusConflict, "سفارش فیزیکی فعال دارید") - return - } - } - var req struct { - RecipientName string `json:"recipientName"` - Phone string `json:"phone"` - Address string `json:"address"` - City string `json:"city"` - PostalCode string `json:"postalCode"` - } - if err := bodyJSON(r, &req); err != nil { - writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") - return - } - ship := models.CertificatePhysicalOrder{ - RecipientName: strings.TrimSpace(req.RecipientName), - Phone: normalizePhone(req.Phone), - Address: strings.TrimSpace(req.Address), - City: strings.TrimSpace(req.City), - PostalCode: strings.TrimSpace(req.PostalCode), - } - if ship.RecipientName == "" { - ship.RecipientName = u.Name - } - if ship.Phone == "" { - ship.Phone = normalizePhone(u.Phone) - } - if ship.Phone == "" || !validPhone(ship.Phone) { - writeErr(w, http.StatusBadRequest, msgPhoneInvalid) - return - } - if ship.Address == "" || ship.City == "" { - writeErr(w, http.StatusBadRequest, "آدرس و شهر برای ارسال الزامی است") - return - } - if len(ship.Address) > 500 || len(ship.City) > 80 || len(ship.RecipientName) > 120 { - writeErr(w, http.StatusBadRequest, "اطلاعات ارسال بیش از حد طولانی است") - return - } - order, err := s.store.CreatePhysicalOrder(r.Context(), cert.ID, u.ID, windowEnds, ship) + writeErr(w, http.StatusGone, "سفارش فیزیکی موقتاً غیرفعال است") +} + +func (s *Server) handleAdminIssueCertificate(w http.ResponseWriter, r *http.Request) { + userID := routeID(r, "id") + u, err := s.store.GetUserByID(r.Context(), userID) if err != nil { - writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد") + writeErr(w, http.StatusNotFound, "کاربر پیدا نشد") + return + } + existing, err := s.store.GetCertificateByUser(r.Context(), userID) + if err != nil && err != store.ErrNotFound { + writeInternalErr(w, "cert.admin.issue", err, "بارگذاری گواهینامه ممکن نشد") + return + } + if existing != nil && existing.RevokedAt == nil && existing.Status == "issued" { + writeErr(w, http.StatusConflict, "گواهینامه قبلاً صادر شده است") + return + } + publicID, err := newPublicID() + if err != nil { + writeInternalErr(w, "cert.admin.pubid", err, "تولید شناسه ممکن نشد") + return + } + var cert *models.Certificate + if existing != nil { + cert, err = s.store.IssuePendingCertificate(r.Context(), userID, publicID, u.Name) + } else { + cert, err = s.store.CreateCertificate(r.Context(), userID, publicID, u.Name) + } + if err != nil { + writeInternalErr(w, "cert.admin.create", err, "صدور گواهینامه ممکن نشد") return } - staff, _ := s.store.ListMentors(r.Context()) go func() { - for _, m := range staff { - if m.Role != models.RoleAdmin { - continue - } - _ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order", - "سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin", - map[string]any{"orderId": order.ID, "userId": u.ID}) - } + _ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued", + "گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap", + map[string]any{"publicId": cert.PublicID}) }() - observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID) - writeJSON(w, http.StatusCreated, map[string]any{ - "order": order, - "message": "پس از واریز با ادمین هماهنگ کنید", - "physical": settings, - }) + observability.Activity("certificate.admin_issued", "user_id", userID, "actor_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"certificate": cert}) +} + +func (s *Server) handleAdminRevokeCertificate(w http.ResponseWriter, r *http.Request) { + userID := routeID(r, "id") + if err := s.store.RevokeCertificate(r.Context(), userID); err != nil { + writeInternalErr(w, "cert.admin.revoke", err, "لغو گواهینامه ممکن نشد") + return + } + observability.Activity("certificate.admin_revoked", "user_id", userID, "actor_id", currentUser(r).ID) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (s *Server) handleAdminListPendingCertificates(w http.ResponseWriter, r *http.Request) { + certs, err := s.store.ListCertificatesByStatus(r.Context(), "pending") + if err != nil { + writeInternalErr(w, "cert.admin.pending", err, "بارگذاری گواهینامه‌های در انتظار ممکن نشد") + return + } + writeJSON(w, http.StatusOK, map[string]any{"certificates": certs}) } func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/leaderboard.go b/backend/internal/api/leaderboard.go index 5fc929b..4270cbc 100644 --- a/backend/internal/api/leaderboard.go +++ b/backend/internal/api/leaderboard.go @@ -3,6 +3,7 @@ package api import ( "context" "net/http" + "sort" "strconv" "time" @@ -15,21 +16,40 @@ type lbEntry struct { Email string `json:"email"` XP float64 `json:"xp"` Rank int `json:"rank"` + Tier string `json:"tier"` AvatarVariant string `json:"avatarVariant"` AvatarPalette string `json:"avatarPalette"` AvatarPhoto string `json:"avatarPhoto,omitempty"` } +// tierFromRank assigns a league tier based on rank relative to total participants. +// Thresholds: rank 1 = champion, top 10% = gold, top 30% = silver, rest = bronze. +func tierFromRank(rank, total int) string { + if rank <= 0 || total <= 0 { + return "bronze" + } + if rank == 1 { + return "champion" + } + if rank*10 <= total { + return "gold" + } + if rank*10 <= total*3 { + return "silver" + } + return "bronze" +} + func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) { u := currentUser(r) key := "lb:" + service.ISOWeek(time.Now()) - entries := make([]lbEntry, 0, 20) type scored struct { userID int64 xp float64 } var scoredList []scored + var totalParticipants int64 if s.redis != nil { z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result() @@ -39,12 +59,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) scoredList = append(scoredList, scored{userID: id, xp: member.Score}) } } + if count, err := s.redis.ZCard(context.Background(), key).Result(); err == nil { + totalParticipants = count + } } - // fallback to DB if redis empty (e.g. dev bootstrap) + // Fallback to DB if Redis is empty (e.g. dev bootstrap). if len(scoredList) == 0 { - rows, err := s.store.Pool().Query(context.Background(), ` - SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 ORDER BY xp DESC LIMIT 20`, + rows, err := s.store.Pool().Query(context.Background(), + `SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1 + ORDER BY xp DESC, user_id ASC LIMIT 20`, service.ISOWeek(time.Now())) if err == nil { for rows.Next() { @@ -56,30 +80,50 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) } rows.Close() } + if err := s.store.Pool().QueryRow(context.Background(), + `SELECT COUNT(*) FROM weekly_leaderboard WHERE week=$1`, + service.ISOWeek(time.Now())).Scan(&totalParticipants); err != nil { + totalParticipants = int64(len(scoredList)) + } } + // Deterministic tie-breaking: XP descending, then user_id ascending. + sort.SliceStable(scoredList, func(i, j int) bool { + if scoredList[i].xp != scoredList[j].xp { + return scoredList[i].xp > scoredList[j].xp + } + return scoredList[i].userID < scoredList[j].userID + }) + + entries := make([]lbEntry, 0, len(scoredList)) for i, sc := range scoredList { usr, err := s.store.GetUserByID(context.Background(), sc.userID) if err != nil { continue } s.signUserMedia(usr) + rank := i + 1 entries = append(entries, lbEntry{ - UserID: sc.userID, Name: usr.Name, Email: usr.Email, XP: sc.xp, Rank: i + 1, - AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto, + UserID: sc.userID, + Name: usr.Name, + Email: usr.Email, + XP: sc.xp, + Rank: rank, + Tier: tierFromRank(rank, int(totalParticipants)), + AvatarVariant: usr.AvatarVariant, + AvatarPalette: usr.AvatarPalette, + AvatarPhoto: usr.AvatarPhoto, }) } - // my position + // Current user's position (may be outside top 20). myRank := -1 var myXP float64 if s.redis != nil { - rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result() - if err == nil { + if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil { myRank = int(rank) + 1 } - score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result() - if err == nil { + if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil { myXP = score } } @@ -88,8 +132,14 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) entries = []lbEntry{} } writeJSON(w, http.StatusOK, map[string]any{ - "week": service.ISOWeek(time.Now()), - "entries": entries, - "me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent}, + "week": service.ISOWeek(time.Now()), + "entries": entries, + "totalParticipants": totalParticipants, + "me": map[string]any{ + "rank": myRank, + "xp": myXP, + "streak": u.StreakCurrent, + "tier": tierFromRank(myRank, int(totalParticipants)), + }, }) } diff --git a/backend/internal/api/leaderboard_test.go b/backend/internal/api/leaderboard_test.go new file mode 100644 index 0000000..01e007c --- /dev/null +++ b/backend/internal/api/leaderboard_test.go @@ -0,0 +1,31 @@ +package api + +import "testing" + +func TestTierFromRank(t *testing.T) { + cases := []struct { + rank, total int + want string + }{ + {1, 1, "champion"}, + {1, 100, "champion"}, + {1, 0, "bronze"}, // zero total → bronze (guard) + {0, 100, "bronze"}, // zero rank → bronze (guard) + {10, 100, "gold"}, // exactly 10% + {11, 100, "silver"}, // 11% + {30, 100, "silver"}, // exactly 30% + {31, 100, "bronze"}, // 31% + {5, 50, "gold"}, // 10% + {15, 50, "silver"}, // exactly 30% + {16, 50, "bronze"}, // 32% → bronze + {50, 100, "bronze"}, + {2, 10, "silver"}, // 20% → silver + {1, 10, "champion"}, + } + for _, c := range cases { + got := tierFromRank(c.rank, c.total) + if got != c.want { + t.Errorf("tierFromRank(%d, %d) = %q; want %q", c.rank, c.total, got, c.want) + } + } +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 925779a..f806dda 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -101,6 +101,9 @@ func newRouter(s *Server) http.Handler { mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword)) mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders)) mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder)) + mux.HandleFunc("GET /api/admin/certificates/pending", staff(s.handleAdminListPendingCertificates)) + mux.HandleFunc("POST /api/admin/users/{id}/certificate/issue", staff(s.handleAdminIssueCertificate)) + mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate)) mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings)) mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings)) mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport)) @@ -127,11 +130,13 @@ func newRouter(s *Server) http.Handler { mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter)) mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter)) mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter)) + mux.HandleFunc("POST /api/admin/chapters/{id}/publish", staff(s.handleAdminPublishChapter)) mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons)) mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson)) mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson)) mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson)) + mux.HandleFunc("POST /api/admin/lessons/{id}/publish", staff(s.handleAdminPublishLesson)) mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions)) mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion)) mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion)) diff --git a/backend/internal/api/tree.go b/backend/internal/api/tree.go index b77305d..e91ec98 100644 --- a/backend/internal/api/tree.go +++ b/backend/internal/api/tree.go @@ -57,6 +57,23 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { } } + // Compute progress aggregates using only active+published lessons as the denominator so + // that frontend and backend always agree on the percentage. + totalActive, completedActive := 0, 0 + for _, l := range lessons { + if !l.IsActive || !l.IsPublished { + continue + } + totalActive++ + if p, ok := progress[l.ID]; ok && p.PassedQuiz { + completedActive++ + } + } + var progressPct float64 + if totalActive > 0 { + progressPct = float64(completedActive) / float64(totalActive) * 100 + } + out := make([]chapterView, 0, len(chapters)) for _, ch := range chapters { cv := chapterView{Chapter: ch, Lessons: []lessonView{}} @@ -84,7 +101,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{ - "chapters": out, + "chapters": out, + "totalLessons": totalActive, + "completedLessons": completedActive, + "progressPct": progressPct, "me": map[string]any{ "xp": u.XP, "streak": u.StreakCurrent, @@ -244,7 +264,7 @@ func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesso writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error()) return false } - if !lesson.IsActive && !u.Role.IsStaff() { + if (!lesson.IsActive || !lesson.IsPublished) && !u.Role.IsStaff() { writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد") return false } diff --git a/backend/internal/db/migrations/0030_lesson_draft_status.sql b/backend/internal/db/migrations/0030_lesson_draft_status.sql new file mode 100644 index 0000000..81b1169 --- /dev/null +++ b/backend/internal/db/migrations/0030_lesson_draft_status.sql @@ -0,0 +1,9 @@ +-- Add draft/published state to chapters and lessons. +-- Existing content is published immediately so nothing goes dark on deploy. +-- New lessons created via the admin API default to is_published = false (draft). + +ALTER TABLE lessons ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE chapters ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false; + +UPDATE lessons SET is_published = true; +UPDATE chapters SET is_published = true; diff --git a/backend/internal/db/migrations/0031_certificate_approval.sql b/backend/internal/db/migrations/0031_certificate_approval.sql new file mode 100644 index 0000000..7277d85 --- /dev/null +++ b/backend/internal/db/migrations/0031_certificate_approval.sql @@ -0,0 +1,13 @@ +-- Add manual approval flow for certificates. +-- Existing certs keep status='issued'. New completions create status='pending' +-- until an admin or mentor explicitly issues the certificate. + +ALTER TABLE certificates ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'issued'; +ALTER TABLE certificates ALTER COLUMN public_id DROP NOT NULL; + +UPDATE certificates SET status = 'issued'; + +INSERT INTO app_settings (key, value) VALUES + ('certificate_enabled', 'true'), + ('certificate_requires_approval', 'true') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index a9570fb..61f9e3b 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -107,10 +107,11 @@ func (u *User) ApplyFreezeState(now time.Time) { type Certificate struct { ID int64 `json:"id"` UserID int64 `json:"userId"` - PublicID string `json:"publicId"` + PublicID string `json:"publicId,omitempty"` FullName string `json:"fullName"` IssuedAt time.Time `json:"issuedAt"` RevokedAt *time.Time `json:"revokedAt,omitempty"` + Status string `json:"status"` } type PhysicalOrderStatus string @@ -164,6 +165,7 @@ type Chapter struct { Description string `json:"description"` Icon string `json:"icon"` SortOrder int `json:"sortOrder"` + IsPublished bool `json:"isPublished"` } type Lesson struct { @@ -180,6 +182,7 @@ type Lesson struct { SortOrder int `json:"sortOrder"` XPReward int `json:"xpReward"` IsActive bool `json:"isActive"` + IsPublished bool `json:"isPublished"` } type MCQQuestion struct { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 56c2223..4f1c8a0 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -618,7 +618,7 @@ func IsUniqueViolation(err error) bool { func (s *Store) CountActiveLessons(ctx context.Context) (int, error) { var n int - err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active`).Scan(&n) + err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published`).Scan(&n) return n, err } @@ -627,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM lesson_progress lp JOIN lessons l ON l.id = lp.lesson_id - WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active`, userID).Scan(&n) + WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published`, userID).Scan(&n) return n, err } func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) { + // An issued (non-revoked) certificate is permanent proof of completion regardless + // of subsequent curriculum changes (e.g. new lessons added after the student finished). + if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil { + return true, nil + } active, err := s.CountActiveLessons(ctx) if err != nil { return false, err @@ -648,42 +653,106 @@ func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) { row := s.pool.QueryRow(ctx, ` - SELECT id, user_id, public_id, full_name, issued_at, revoked_at + SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status FROM certificates WHERE user_id=$1`, userID) return scanCertificate(row) } func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) { row := s.pool.QueryRow(ctx, ` - SELECT id, user_id, public_id, full_name, issued_at, revoked_at - FROM certificates WHERE public_id=$1`, publicID) + SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status + FROM certificates WHERE public_id=$1 AND status='issued'`, publicID) return scanCertificate(row) } func scanCertificate(row rowScanner) (*models.Certificate, error) { var c models.Certificate - err := row.Scan(&c.ID, &c.UserID, &c.PublicID, &c.FullName, &c.IssuedAt, &c.RevokedAt) + var publicID *string + err := row.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } + if publicID != nil { + c.PublicID = *publicID + } return &c, nil } func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) { row := s.pool.QueryRow(ctx, ` - INSERT INTO certificates (user_id, public_id, full_name) - VALUES ($1, $2, $3) + INSERT INTO certificates (user_id, public_id, full_name, status) + VALUES ($1, $2, $3, 'issued') ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name, + public_id = EXCLUDED.public_id, + status = 'issued', revoked_at = NULL - RETURNING id, user_id, public_id, full_name, issued_at, revoked_at`, + RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`, userID, publicID, fullName) return scanCertificate(row) } +func (s *Store) CreatePendingCertificate(ctx context.Context, userID int64, fullName string) (*models.Certificate, error) { + row := s.pool.QueryRow(ctx, ` + INSERT INTO certificates (user_id, public_id, full_name, status) + VALUES ($1, NULL, $2, 'pending') + ON CONFLICT (user_id) DO NOTHING + RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`, + userID, fullName) + cert, err := scanCertificate(row) + if errors.Is(err, ErrNotFound) { + return s.GetCertificateByUser(ctx, userID) + } + return cert, err +} + +func (s *Store) IssuePendingCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) { + row := s.pool.QueryRow(ctx, ` + UPDATE certificates SET + public_id = $1, + full_name = $2, + status = 'issued', + revoked_at = NULL, + issued_at = NOW() + WHERE user_id = $3 + RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`, + publicID, fullName, userID) + return scanCertificate(row) +} + +func (s *Store) RevokeCertificate(ctx context.Context, userID int64) error { + _, err := s.pool.Exec(ctx, `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1`, userID) + return err +} + +func (s *Store) ListCertificatesByStatus(ctx context.Context, status string) ([]models.Certificate, error) { + rows, err := s.pool.Query(ctx, ` + SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status + FROM certificates c + WHERE c.status=$1 AND c.revoked_at IS NULL + ORDER BY c.issued_at DESC`, status) + if err != nil { + return nil, err + } + defer rows.Close() + var out []models.Certificate + for rows.Next() { + var c models.Certificate + var publicID *string + if err := rows.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status); err != nil { + return nil, err + } + if publicID != nil { + c.PublicID = *publicID + } + out = append(out, c) + } + return out, rows.Err() +} + const physicalOrderCols = `id, certificate_id, user_id, status, note, recipient_name, phone, address, city, postal_code, tracking_code, window_ends_at, created_at, updated_at` @@ -879,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error { // ---- Chapters / Lessons / Questions ---- func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) { + c.IsPublished = false err := s.pool.QueryRow(ctx, ` - INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`, - c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.ID) + INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id`, + c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished).Scan(&c.ID) return c.ID, err } func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error { _, err := s.pool.Exec(ctx, ` - UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`, - c.Title, c.Description, c.Icon, c.SortOrder, id) + UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6`, + c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished, id) + return err +} + +func (s *Store) PublishChapter(ctx context.Context, id int64, publish bool) error { + _, err := s.pool.Exec(ctx, `UPDATE chapters SET is_published=$1 WHERE id=$2`, publish, id) return err } @@ -898,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) error { } func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) { - rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order FROM chapters ORDER BY sort_order, id`) + rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id`) if err != nil { return nil, err } @@ -906,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) { var out []models.Chapter for rows.Next() { var c models.Chapter - if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder); err != nil { + if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder, &c.IsPublished); err != nil { return nil, err } out = append(out, c) @@ -915,22 +990,28 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) { } func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) { + l.IsPublished = false err := s.pool.QueryRow(ctx, ` INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds, - completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`, + completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`, l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds, - l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive).Scan(&l.ID) + l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished).Scan(&l.ID) return l.ID, err } func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error { _, err := s.pool.Exec(ctx, ` UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5, - completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12 - WHERE id=$13`, + completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13 + WHERE id=$14`, l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds, - l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, id) + l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished, id) + return err +} + +func (s *Store) PublishLesson(ctx context.Context, id int64, publish bool) error { + _, err := s.pool.Exec(ctx, `UPDATE lessons SET is_published=$1 WHERE id=$2`, publish, id) return err } @@ -942,11 +1023,11 @@ func (s *Store) DeleteLesson(ctx context.Context, id int64) error { func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) { row := s.pool.QueryRow(ctx, ` SELECT id, chapter_id, title, description, video_key, duration_seconds, - completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active + completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons WHERE id=$1`, id) var l models.Lesson err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds, - &l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive) + &l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } @@ -955,9 +1036,9 @@ func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) { q := `SELECT id, chapter_id, title, description, video_key, duration_seconds, - completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active FROM lessons` + completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons` if !includeInactive { - q += ` WHERE is_active` + q += ` WHERE is_active AND is_published` } q += ` ORDER BY chapter_id, sort_order, id` rows, err := s.pool.Query(ctx, q) @@ -969,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models for rows.Next() { var l models.Lesson if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds, - &l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive); err != nil { + &l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished); err != nil { return nil, err } out = append(out, l) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ecd9eb1..da9acb1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,13 +16,19 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@tanstack/react-query": "^5.62.0", + "@tiptap/extension-placeholder": "^3.32.0", + "@tiptap/react": "^3.32.0", + "@tiptap/starter-kit": "^3.32.0", + "@types/dompurify": "^3.0.5", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.4.17", "emoji-datasource-apple": "^15.1.2", "framer-motion": "^12.0.0", "lucide-react": "^0.468.0", + "marked": "^18.1.1", "next": "15.5.27", "next-themes": "^0.4.4", "react": "^19.0.0", @@ -30,6 +36,7 @@ "react-multi-date-picker": "^4.5.2", "sonner": "^1.7.1", "tailwind-merge": "^2.6.0", + "tiptap-markdown": "^0.9.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.2" }, @@ -1772,6 +1779,480 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.32.0.tgz", + "integrity": "sha512-Ghh2h02GKSqzytQGcLV4espYOG4EHxjj0MsrUB2Vnr47xmlZYRf6r7k2J09H+JpBdaGrWwrTkyBEi5i3c3yy2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.32.0.tgz", + "integrity": "sha512-wmuthEdlYVArNoUbqFPH35giutBABFu7fqEB0lTD1k0UXJUs4dFvcbRyGWWgnHFZgP3TT4u8e+xiayYw0eQ0NQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.32.0.tgz", + "integrity": "sha512-SQYewnYDoIBN+8oH53IDcD8p+Pz+Jnyq3hrxjy/FAo5ctc+96d3ZLdqc9gzLVSqFFo/SUINv2FVGoq9PvU7yqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.32.0.tgz", + "integrity": "sha512-X3ll8i9Z8QNvt7+tcqlwopJG+68ugO13Av0Ox4Ico+jXcTnFdDuKQJBSI9sLuMR5vTQOeTHhKYXhjF88aahMSA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.32.0.tgz", + "integrity": "sha512-Y5P/l06pVCUG46vXpBl3z0fW2b6JpSMX4Cvr+5sfQh8zB3TxwSiLQaaPwI3R5MFD1KfRk3AjiPRieEyXdWcNHw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.32.0.tgz", + "integrity": "sha512-I+aEVl0dTmkfnU6HzBGEefb9U7ja2MkucwBBwrmoMwU+uIKpC4xw8NcHC9X6sjAcom3Pxs2HX+8WEo4Vp13R/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.32.0.tgz", + "integrity": "sha512-9NZW/yXP9j9XKr+hRS5m58xiyZI+tQo1PJ7eLHXjGNFWaF601NG/h6741mbKh5uCY51XsbyqG+7que+RavngPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.32.0.tgz", + "integrity": "sha512-GugeKE6AnZDluRXJUNBuX43TPcaJxy5wck6vT2J4a0/EnKStJ43suSZZf1IPfb1ij9EL9bYvAwg9DOcrvdEzTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.32.0.tgz", + "integrity": "sha512-t4Ug8hFLt30dwwYvrh63BVUt6N1Yg5PVI3tMllhCmN3Q68oD47v4MB/i5nGfzIsQqMyXUOetwHJWcZ4MoLTx4Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.32.0.tgz", + "integrity": "sha512-AzKCWceL+4Q1pTc8Whw1kPqN/aYGPQ70/jXPsblWl501t/pXi6Kz0Pi/NggKkuu73VGjykh68YYf/dcOJ/mPZQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.32.0.tgz", + "integrity": "sha512-HUpKKWJLtthnpQY5LIjHlJJOmh562RusmOMIe/4iP/H7y+WUR9ZFiZjEs1NBkRjig9fwj8F3BfsrVPYBnM4nLA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.32.0.tgz", + "integrity": "sha512-IKUYvK+bO2hGrDWhhTVg0CSh9C2belWg9GyLzvpyd3SZyQ9EgdccLfoytNjOBKbAE/TQ4Oo3kSP+XF6RsUIKPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.32.0.tgz", + "integrity": "sha512-XRiZxZuhfjvbgwCvoBHj04gOsPANEXxxRwrFCQVuFi5yeTHYIzMEioc81j6iAH9pV7aQeJCwLxuh6VHNz3dXEQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.32.0.tgz", + "integrity": "sha512-Iry7ynsG4xgcxnH1cm+uXJnEtUzoop4sGA10NUN9I+08TOjhj/GrDiRKMQHKAiVI/R7t2MVav1MJlxFf2FdzGw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.32.0.tgz", + "integrity": "sha512-KB/i1xoHilbBGcQ+UotDWhi60KpdCde3RzRIpl2na0Yol4z4Fe2IRWg8ljIrlsssDRwel/fsHJbG7a13EP8oKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.32.0.tgz", + "integrity": "sha512-DRdQSgWPSEjrDg5EDBB5yxRygHtUVLhceVGzAsTz8RziiFyvcwAvzBHCO9hd9jutjCXQ2DLIuZB8P+aHhFWnpg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.32.0.tgz", + "integrity": "sha512-QdzS5nFz9agAQhBgIuQluobWib39W2cMKx1XB6tYSue7bHMJdgPFP0EnGfFajaeVX42CG6RXtQWrNfyQGKhBvg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.32.0.tgz", + "integrity": "sha512-FdJTCAeALixzyyGSSEmroXmkIEclbo/4t2Pl03aRERqpPOSPJcC3evDtIhLazTuP6v+FjCmJ+pXNql6ov7qitA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.32.0.tgz", + "integrity": "sha512-vXFre18Dk2m5N/gjo408C02GSrolZ8ZIf0wz6/U8lNC4FcYOISgZFj3BND6uabVntGh2GCSPrVFuNpVPhFnn/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.32.0.tgz", + "integrity": "sha512-htZ5hoMLBkGiDP6M5mGemjOoaBmkrj911wiGoTmMMKzO7qJq9QxUBkLY7ILPaKlUWL8U3ccLdDV2TB/nysp4Jg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.32.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.32.0.tgz", + "integrity": "sha512-afKLMc7fWMkkar9H5FilizStSbhFzUoCzdwwdZf6DDsn3LUt8tJlFHACK78cAyx/TzMJBo0N8/8T1u9bL+N67Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.32.0.tgz", + "integrity": "sha512-TSjyQmVFq56gfURPotxbMsXU3seNTy9VzMACdb6b0bQ66yGWBcjBfWmhpiSRVLdBjQrRvl1+jQbWx0YPSCSpsw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.32.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.32.0.tgz", + "integrity": "sha512-J9Vw6wmh/wV6SxSsN9RhurvAGcXSY5J3p4EQrROD5PARBl0VOnDFi7/UVoz1RkSdqOKPgAiqDZpaLRgtdBO9Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.32.0.tgz", + "integrity": "sha512-sF0jIK67IHdZMnzv2mz5vPTyK7R0ui6/YZgFg+/bLnFCNmweVAoYcHZLlC7ISRnwu0mqJr+epNtzJepY1ZDCjg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.32.0.tgz", + "integrity": "sha512-rMLdd4vg6sed/JnaXnaupLbCtYiWikTnQ8qir9ItKOXwf1Ui9P1Y4G0bYU4+gcnMsFzaSGtrOhMqG4/c66AogQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.32.0.tgz", + "integrity": "sha512-qO+ndNvlOjzU2MWCtQHoRg1tIortCXLvqPjDx58F1pUYFNCOVZegXi+jqLGDODUA31er+TVz66umzRbbN1bO1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.32.0.tgz", + "integrity": "sha512-H9eN+xHFboJybE7KPGVsu24S8aBlCIgYz38vCHRSnNv17nwmqm06owq/IQ5scza0n3Y7IfUfpgPOtm9SgeHmNQ==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.4", + "prosemirror-commands": "^1.7.2", + "prosemirror-dropcursor": "^1.8.4", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.1", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.12", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.2", + "prosemirror-view": "^1.42.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.32.0.tgz", + "integrity": "sha512-+iS9RKj6Ve8SXqJ49xptDiifF7PQXmUOh4cjMu+BCXXqKphQkiUccvbWYAeOJCTceM01wWgFhrpfy8qG+CHJYw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "3.32.0", + "@tiptap/extension-floating-menu": "3.32.0" + }, + "peerDependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/pm": "3.32.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.32.0.tgz", + "integrity": "sha512-aPYxaevci4PgMrTLsg+baiqI7M1ICd4V9H+zTTdrqs8OUZIbAvj/pNDuHU8uWZyKMu3Dh72t2D6ZdqRu/6aSWw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.32.0", + "@tiptap/extension-blockquote": "3.32.0", + "@tiptap/extension-bold": "3.32.0", + "@tiptap/extension-bullet-list": "3.32.0", + "@tiptap/extension-code": "3.32.0", + "@tiptap/extension-code-block": "3.32.0", + "@tiptap/extension-document": "3.32.0", + "@tiptap/extension-dropcursor": "3.32.0", + "@tiptap/extension-gapcursor": "3.32.0", + "@tiptap/extension-hard-break": "3.32.0", + "@tiptap/extension-heading": "3.32.0", + "@tiptap/extension-horizontal-rule": "3.32.0", + "@tiptap/extension-italic": "3.32.0", + "@tiptap/extension-link": "3.32.0", + "@tiptap/extension-list": "3.32.0", + "@tiptap/extension-list-item": "3.32.0", + "@tiptap/extension-list-keymap": "3.32.0", + "@tiptap/extension-ordered-list": "3.32.0", + "@tiptap/extension-paragraph": "3.32.0", + "@tiptap/extension-strike": "3.32.0", + "@tiptap/extension-text": "3.32.0", + "@tiptap/extension-underline": "3.32.0", + "@tiptap/extensions": "3.32.0", + "@tiptap/pm": "3.32.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "13.0.9", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", + "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^3", + "@types/mdurl": "^1" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", @@ -1786,7 +2267,6 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1796,12 +2276,29 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -1875,7 +2372,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/date-fns": { @@ -1904,6 +2400,15 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/emoji-datasource-apple": { "version": "15.1.2", "resolved": "https://registry.npmjs.org/emoji-datasource-apple/-/emoji-datasource-apple-15.1.2.tgz", @@ -1924,6 +2429,27 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fast-equals": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.5.tgz", + "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/framer-motion": { "version": "12.43.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", @@ -2238,6 +2764,31 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/lucide-react": { "version": "0.468.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", @@ -2257,6 +2808,57 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-task-lists": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz", + "integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==", + "license": "ISC" + }, + "node_modules/marked": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz", + "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/motion-dom": { "version": "12.43.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", @@ -2352,6 +2954,12 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2386,6 +2994,187 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.8.tgz", + "integrity": "sha512-Zi43BXX28jmxDJKvk8TDS2gMkBY/mgP8ynd6l/Vxrv8rgNFs5hsV1G0VscxTID1UCzVPcidADP9fUOBY3xxUeA==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-markdown/node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/prosemirror-markdown/node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/prosemirror-markdown/node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", + "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", + "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -2506,6 +3295,12 @@ } } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -2648,6 +3443,24 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiptap-markdown": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz", + "integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "dependencies": { + "@types/markdown-it": "^13.0.7", + "markdown-it": "^14.1.0", + "markdown-it-task-lists": "^2.1.1", + "prosemirror-markdown": "^1.11.1" + }, + "peerDependencies": { + "@tiptap/core": "^3.0.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2677,6 +3490,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2727,6 +3546,21 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/zustand": { "version": "5.0.15", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index d850371..4489491 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,13 +20,19 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@tanstack/react-query": "^5.62.0", + "@tiptap/extension-placeholder": "^3.32.0", + "@tiptap/react": "^3.32.0", + "@tiptap/starter-kit": "^3.32.0", + "@types/dompurify": "^3.0.5", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "dompurify": "^3.4.17", "emoji-datasource-apple": "^15.1.2", "framer-motion": "^12.0.0", "lucide-react": "^0.468.0", + "marked": "^18.1.1", "next": "15.5.27", "next-themes": "^0.4.4", "react": "^19.0.0", @@ -34,6 +40,7 @@ "react-multi-date-picker": "^4.5.2", "sonner": "^1.7.1", "tailwind-merge": "^2.6.0", + "tiptap-markdown": "^0.9.0", "tw-animate-css": "^1.4.0", "zustand": "^5.0.2" }, diff --git a/frontend/src/app/c/[id]/page.tsx b/frontend/src/app/c/[id]/page.tsx index f995474..0a930ce 100644 --- a/frontend/src/app/c/[id]/page.tsx +++ b/frontend/src/app/c/[id]/page.tsx @@ -1,45 +1,23 @@ "use client"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; -import { Loader2, Package } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { CertificateCard } from "@/components/certificate-card"; import { NotFoundState, notFoundSentence } from "@/components/not-found-state"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { http, toUserError } from "@/lib/api"; import { useAuth } from "@/lib/auth-store"; -import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types"; -import { toast } from "@/components/providers"; -import { formatFaNumber } from "@/lib/utils"; -import { clampPhoneInput, normalizePhone, PHONE_INPUT_MAX_LEN } from "@/lib/validation"; -import { useEffect, useState } from "react"; +import type { Certificate } from "@/lib/types"; type PublicCertResponse = { certificate: { publicId: string; fullName: string; issuedAt: string }; course: string; }; -const STATUS_LABEL: Record = { - requested: "درخواست‌شده — منتظر تأیید پرداخت", - paid: "پرداخت تأیید شد — در صف ارسال", - shipped: "ارسال شد", - cancelled: "لغو شده", -}; - export default function CertificatePage() { const params = useParams<{ id: string }>(); const publicId = params?.id ?? ""; const authUser = useAuth((s) => s.user); - const qc = useQueryClient(); - const [requesting, setRequesting] = useState(false); - const [form, setForm] = useState({ - recipientName: "", - phone: "", - address: "", - city: "", - postalCode: "", - }); const { data, isLoading, error } = useQuery({ queryKey: ["certificate", publicId], @@ -50,42 +28,12 @@ export default function CertificatePage() { const { data: mine } = useQuery({ queryKey: ["me", "certificate"], - queryFn: () => - http.get<{ - certificate: Certificate | null; - physicalOrder: CertificatePhysicalOrder | null; - physical: PhysicalCertSettings; - }>("/api/me/certificate"), + queryFn: () => http.get<{ certificate: Certificate | null }>("/api/me/certificate"), enabled: !!authUser, }); const isOwner = !!authUser && mine?.certificate?.publicId === publicId; - useEffect(() => { - if (!isOwner || !authUser) return; - setForm((f) => ({ - ...f, - recipientName: f.recipientName || authUser.name || "", - phone: f.phone || authUser.phone || "", - })); - }, [isOwner, authUser]); - - const requestPhysical = async () => { - setRequesting(true); - try { - await http.post("/api/me/certificate/physical", { - ...form, - phone: normalizePhone(form.phone), - }); - toast.success("درخواست ثبت شد — پس از واریز با ادمین هماهنگ کنید"); - qc.invalidateQueries({ queryKey: ["me", "certificate"] }); - } catch (e) { - toast.error(toUserError(e)); - } finally { - setRequesting(false); - } - }; - if (isLoading) { return (
@@ -110,17 +58,6 @@ export default function CertificatePage() { } const cert = data.certificate; - const order = isOwner ? mine?.physicalOrder : null; - const physical = isOwner ? mine?.physical : null; - const windowEndsAt = mine?.certificate?.issuedAt - ? new Date(mine.certificate.issuedAt).getTime() + (physical?.windowDays ?? 30) * 86400000 - : 0; - const windowStillOpen = !windowEndsAt || Date.now() < windowEndsAt; - const windowOpen = - physical?.enabled && - windowStillOpen && - (!order || order.status === "cancelled") && - !!mine?.certificate; return ( - {isOwner && physical?.enabled && ( -
-

- نسخه فیزیکی -

-

- قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "} - {formatFaNumber(physical.windowDays)} روز از صدور - {windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}. - پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش به‌روز شود. -

- {order && order.status !== "cancelled" ? ( -
-

- وضعیت: {STATUS_LABEL[order.status] ?? order.status} -

- {order.trackingCode && ( -

- کد پیگیری: {order.trackingCode} -

- )} - {order.city && ( -

- ارسال به {order.recipientName || "—"} · {order.city} -

- )} - {order.note &&

یادداشت ادمین: {order.note}

} - -
- ) : windowOpen ? ( -
-
- setForm((f) => ({ ...f, recipientName: e.target.value }))} - /> - setForm((f) => ({ ...f, phone: clampPhoneInput(e.target.value) }))} - dir="ltr" - inputMode="tel" - maxLength={PHONE_INPUT_MAX_LEN} - className="text-left" - /> - setForm((f) => ({ ...f, city: e.target.value }))} - /> - setForm((f) => ({ ...f, postalCode: e.target.value }))} - dir="ltr" - /> -
-