diff --git a/SET.md b/SET.md index 63d9d02..360afa1 100644 --- a/SET.md +++ b/SET.md @@ -148,6 +148,69 @@ POST https://example.com/api/import/post |`fcm_json_universe_domain`| +## ממשק ניהול ההגדרות (UI חזותי) +ממשק ההגדרות עוצב מחדש לתצוגה חזותית מסודרת במקום טבלת key/value. ההגדרות מחולקות לקטגוריות בכרטיסים נפרדים, כל הגדרה מוצגת עם תווית והסבר בעברית, ולכל סוג שדה רכיב מתאים: +* בוליאנים מוצגים כ-toggle (כן/לא). +* שדות מספריים, טקסט, סיסמה ו-URL מקבלים רכיבים מתאימים. +* שדות ארוכים (כגון snippets ו-private keys) מוצגים כ-textarea. +* שדות התלויים בהפעלת תכונה ראשית (כגון FCM) מסתתרים אוטומטית כשהתכונה מכובה. + +הקטגוריות הקיימות: +* כללי +* אנליטיקס וטראקינג +* הזדהות ואבטחה +* מונה צפיות +* פרסומות (iframe סטטי בצד הערוץ) +* וובהוק +* התראות דחיפה (FCM) +* חשבון שירות FCM (Service Account) - כולל אפשרות הדבקת קובץ JSON שלם עם פיצול אוטומטי לכל השדות +* החלפות טקסט אוטומטיות (רשימה דינמית של כללי regex עם שדות תבנית והחלפה נפרדים) +* הגדרות מתקדמות / מותאמות אישית (אזור מתקפל בתחתית עם הטבלה הישנה של key/value חופשי, לטובת הגדרות שאינן בסכמת ה-UI) + +תחת המכסה, השמירה ממירה את הכל בחזרה למבנה ה-API הקיים `[{key, value}]` כך שאין שינוי בהיסט ה-Redis (`settings:list`). + +## תג אנליטיקס בראש האתר +ניתן להזין כל קוד HTML/JS של שירות אנליטיקס (לדוגמא Google Analytics gtag.js, Google Tag Manager, Meta Pixel וכו') תחת ההגדרה: +`analytics_head` + +הקוד יוזרק על ידי השרת לתוך תגית `` של ה-`index.html` בכל בקשת עמוד SPA. ההזרקה מתבצעת בצד השרת לפני שהדפדפן מקבל את ה-HTML, כך שהקוד פועל מהרגע הראשון. + +## שילוב פרסומות ממגנט (Magnet) +Magnet היא פלטפורמת פרסומות חיצונית. ניתן לשלב פרסומות שלה כהודעות בתוך הערוץ. הניהול דרך לשונית ייעודית בממשק הניהול: **שילוב פרסומות ממגנט**. + +### זרימה ועקרונות +* הפרסומת מוטמעת כקוד embed (HTML/JS) שמספקת מגנט. +* כל פרסומת מתרנדרת רק כשהגולש גולל אליה (`IntersectionObserver` עם `rootMargin: 200px`), כך שהקריאה לשרת מגנט מתבצעת בזמן אמת לכל גולש בנפרד. +* כל טעינה מחדש של הסלוט מבצעת קריאה חדשה — כל גולש יכול לקבל פרסומת אחרת ובכל גלילה גם אותה גולש יכול לראות פרסומת אחרת. +* אם בתוך 5 שניות מההזרקה הסלוט נשאר ריק (אין DOM/אין גובה), הוא מתקפל אוטומטית (`display: none`) — נופל בשקט לכלום במקום להציג אזור ריק. +* הפרסומת מתרנדרת בתוך בועת הודעה כמו הודעת מנהל רגילה (לוגו הערוץ + שם + תווית "פרסומת" + הקוד המוטמע בתוך `.message-card`). + +### תדירות הצגה +שני מצבים בלעדיים, נבחרים דרך ה-UI: + +**מצב א' — לפי כמות הודעות (`magnet_mode=by_messages`)** +* `magnet_per_messages` — הצגת פרסומת כל X הודעות (לדוגמא 5 = פרסומת אחרי כל 5 הודעות). +* `magnet_min_time_seconds` — החרגה: גם אם עברו X הודעות, אם הפרסומת הקודמת הוצגה לפני פחות מהזמן הזה - לא תוצג שוב. 0 לביטול. + +**מצב ב' — לפי זמן (`magnet_mode=by_time`)** +* `magnet_per_seconds` — הצגת פרסומת כל X שניות (לפי הפרשי הזמנים בין ההודעות). +* `magnet_min_messages_since` — החרגה: גם אם עבר הזמן, אם לא נוספו לפחות X הודעות חדשות מאז הפרסומת הקודמת - לא תוצג שוב. 0 לביטול. + +### נתוני הקלקות ותגמולים +בלשונית מגנט קיים כרטיס ייעודי לצפייה בנתוני הקלקות ותגמולים בזמן אמת ממגנט. +* יש להזין `magnet_api_key` (publisher key שמתקבל ממגנט) ולשמור. +* כפתור **הצג נתוני הקלקות ותגמולים** טוען את הנתונים. הקריאה למגנט מתבצעת מצד השרת בלבד דרך endpoint מוגן `GET /api/admin/magnet/stats` כך שה-API key לא נחשף לדפדפן. +* כפתור **רענן** מבצע קריאה חדשה (מגנט שומר תוצאות במטמון לכ-30 שניות). +* התצוגה כוללת: דומיין האתר, כמות הקלקות (היום / שבוע / חודש), ותגמולים (היום / שבוע / חודש) בפורמט מטבע. +* "היום" מתחיל מ-00:00 שעון ישראל. "שבוע"/"חודש" = 7/30 ימים אחורה. +* רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ. + +ה-endpoint שמגנט חושף: +`GET https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/publisher-stats?k=YOUR_API_KEY` + +### הערה ארכיטקטורית +ה-injection של פרסומות לתוך רשימת הצ'אט נעשה ע"י תוספת בתוך ה-`` של ההודעה (לא יצירת `` שני בכל iteration), כדי לא לשבור את ה-content children של `` של Nebular. ה-component `chat.component` שומר על `Set` של ה-IDs של ההודעות שאחריהן יש להציג פרסומת, ומחשב מחדש את ה-Set בכל שינוי ב-`messages` (טעינה ראשונית, scroll, SSE). + ## ריכוז הגדרות בממשק ניהול |setting |value | הסבר | |---------------|------|------| @@ -156,11 +219,26 @@ POST https://example.com/api/import/post |`api_secret_key`|`1`|מפתח עבור יבוא הודעות באמצעות API| |`webhook_url`|`https://example.com/webhook`|כתובת לשליחת וובהוק| |`webhook_verify_token`|`your-secret-token`|טוקן לשליחה יחד עם וובהוק| -|`ad-iframe-src`| |קישור HTML להטמעת פרסומת| -|`ad-iframe-width`|`300`|רוחב פרסום| +|`ad-iframe-src`| |קישור HTML להטמעת פרסומת בעמודה הצידית| +|`ad-iframe-width`|`300`|רוחב פרסום בעמודה הצידית| |`count_views`|`1`|הפעלת מונה צפיות פר הודעה| |`regex-replace`|`(.*?\!)(.*)#**$1**$2`|ערך של רגקס והחלפה בכדי ליצור החלפות אוטומטיות לטקסטים| |`on_notification`|`1`|הפעלת התראות דחיפה| |`max_file_size`|`50`|הגבלת משקל קבצים| |`custom_title`||title מותאם אישית| -|`contact_us`|url|הפעלת כפתור צור קשר| \ No newline at end of file +|`contact_us`|url|הפעלת כפתור צור קשר| +|`analytics_head`|``|קוד אנליטיקס שיוזרק לתוך תגית `` של כל עמוד| +|`magnet_enabled`|`1`|הפעלת שילוב פרסומות ממגנט בערוץ| +|`magnet_snippet`|``|קוד הטמעה (HTML/JS) שהתקבל ממגנט| +|`magnet_mode`|`by_messages` / `by_time`|שיטת תזמון הצגת פרסומות| +|`magnet_per_messages`|`5`|בתזמון לפי הודעות: כל כמה הודעות להציג פרסומת| +|`magnet_min_time_seconds`|`0`|בתזמון לפי הודעות: זמן מינימום בשניות בין פרסומות (0 = ללא החרגה)| +|`magnet_per_seconds`|`60`|בתזמון לפי זמן: כל כמה שניות להציג פרסומת| +|`magnet_min_messages_since`|`0`|בתזמון לפי זמן: מינימום הודעות חדשות מאז הפרסומת הקודמת (0 = ללא החרגה)| +|`magnet_api_key`|`d8059fc0-...`|מפתח API של מגנט לשליפת נתוני הקלקות ותגמולים| + +## API endpoints נוספים שנוספו +| Method | Path | הסבר | +|--------|------|------| +|GET|`/api/ads/magnet`|מחזיר את הגדרות מגנט הציבוריות (snippet, mode, thresholds) לקליינט. ה-snippet מוחזר רק כש-`magnet_enabled=1`. `magnet_api_key` לעולם לא מוחזר.| +|GET|`/api/admin/magnet/stats`|Admin protected. עושה proxy ל-publisher-stats של מגנט עם ה-API key מההגדרות, כך שה-key לא נחשף לדפדפן.| \ No newline at end of file diff --git a/backend/ads.go b/backend/ads.go index b868cb4..bbda9e3 100644 --- a/backend/ads.go +++ b/backend/ads.go @@ -2,7 +2,10 @@ package main import ( "encoding/json" + "io" "net/http" + "net/url" + "time" ) type AdsSettings struct { @@ -19,3 +22,70 @@ func getAdsSettings(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } + +type MagnetAdsSettings struct { + Enabled bool `json:"enabled"` + Snippet string `json:"snippet"` + Mode string `json:"mode"` + PerMessages int64 `json:"perMessages"` + MinTimeSeconds int64 `json:"minTimeSeconds"` + PerSeconds int64 `json:"perSeconds"` + MinMessagesSinceLast int64 `json:"minMessagesSinceLast"` +} + +func getMagnetAdsSettings(w http.ResponseWriter, r *http.Request) { + settings := MagnetAdsSettings{ + Enabled: settingConfig.MagnetEnabled, + Mode: settingConfig.MagnetMode, + PerMessages: settingConfig.MagnetPerMessages, + MinTimeSeconds: settingConfig.MagnetMinTimeSeconds, + PerSeconds: settingConfig.MagnetPerSeconds, + MinMessagesSinceLast: settingConfig.MagnetMinMessagesSince, + } + + if settings.Enabled { + settings.Snippet = settingConfig.MagnetSnippet + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(settings) +} + +const magnetStatsURL = "https://rucltqmtefvlrjhbedqu.supabase.co/functions/v1/publisher-stats" + +var magnetStatsClient = &http.Client{Timeout: 15 * time.Second} + +func getMagnetStats(w http.ResponseWriter, r *http.Request) { + apiKey := settingConfig.MagnetApiKey + if apiKey == "" { + http.Error(w, `{"error":"missing_api_key","message":"Magnet API key is not configured"}`, http.StatusBadRequest) + return + } + + q := url.Values{} + q.Set("k", apiKey) + reqURL := magnetStatsURL + "?" + q.Encode() + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, reqURL, nil) + if err != nil { + http.Error(w, `{"error":"request_build_failed"}`, http.StatusInternalServerError) + return + } + + resp, err := magnetStatsClient.Do(req) + if err != nil { + http.Error(w, `{"error":"upstream_unreachable"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, `{"error":"upstream_read_failed"}`, http.StatusBadGateway) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} diff --git a/backend/main.go b/backend/main.go index a33d03c..d298506 100644 --- a/backend/main.go +++ b/backend/main.go @@ -74,6 +74,7 @@ func main() { r.Get("/firebase-messaging-sw.js", getFirebaseMessagingSW) r.Route("/api", func(api chi.Router) { api.Get("/ads/settings", getAdsSettings) + api.Get("/ads/magnet", getMagnetAdsSettings) api.Get("/emojis/list", getEmojisList) api.Get("/channel/notifications-config", getNotificationsConfig) api.Post("/channel/notifications-subscribe", subscribeNotifications) @@ -103,6 +104,7 @@ func main() { protected.Post("/privilegs-users/set", protectedWithPrivilege(Admin, setPrivilegeUsers)) protected.Get("/settings/get", protectedWithPrivilege(Admin, getSettings)) protected.Post("/settings/set", protectedWithPrivilege(Admin, setSettings)) + protected.Get("/magnet/stats", protectedWithPrivilege(Admin, getMagnetStats)) protected.Get("/reports/get", protectedWithPrivilege(Admin, getReports)) protected.Post("/reports/set", protectedWithPrivilege(Admin, setReports)) }) @@ -135,6 +137,10 @@ func serveSpaFile(w http.ResponseWriter, r *http.Request) { content = bytes.ReplaceAll(content, []byte(""), []byte(settingConfig.CustomTitle)) } + if settingConfig.AnalyticsHead != "" { + content = bytes.Replace(content, []byte(""), []byte(settingConfig.AnalyticsHead+""), 1) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write(content) } diff --git a/backend/settings.go b/backend/settings.go index c9286a0..71389f0 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -41,6 +41,15 @@ type SettingConfig struct { MaxFileSize int64 CustomTitle string ContactUs string + MagnetEnabled bool + MagnetSnippet string + MagnetMode string + MagnetPerMessages int64 + MagnetMinTimeSeconds int64 + MagnetPerSeconds int64 + MagnetMinMessagesSince int64 + MagnetApiKey string + AnalyticsHead string } type Setting struct { @@ -186,6 +195,33 @@ func (s *Settings) ToConfig() *SettingConfig { case "contact_us": config.ContactUs = setting.GetString() + + case "magnet_enabled": + config.MagnetEnabled = setting.GetBool() + + case "magnet_snippet": + config.MagnetSnippet = setting.GetString() + + case "magnet_mode": + config.MagnetMode = setting.GetString() + + case "magnet_per_messages": + config.MagnetPerMessages = setting.GetInt() + + case "magnet_min_time_seconds": + config.MagnetMinTimeSeconds = setting.GetInt() + + case "magnet_per_seconds": + config.MagnetPerSeconds = setting.GetInt() + + case "magnet_min_messages_since": + config.MagnetMinMessagesSince = setting.GetInt() + + case "magnet_api_key": + config.MagnetApiKey = setting.GetString() + + case "analytics_head": + config.AnalyticsHead = setting.GetString() } } diff --git a/captain-definition b/captain-definition new file mode 100644 index 0000000..debfa9b --- /dev/null +++ b/captain-definition @@ -0,0 +1,4 @@ +{ + "schemaVersion": 2, + "dockerfilePath": "./Dockerfile" +} \ No newline at end of file diff --git a/frontend/src/app/components/admin/admin-panel.component.html b/frontend/src/app/components/admin/admin-panel.component.html index 240f739..f4bfbe8 100644 --- a/frontend/src/app/components/admin/admin-panel.component.html +++ b/frontend/src/app/components/admin/admin-panel.component.html @@ -31,6 +31,9 @@ @case (statistics) { } + @case (magnetAds) { + + } } diff --git a/frontend/src/app/components/admin/admin-panel.component.ts b/frontend/src/app/components/admin/admin-panel.component.ts index 9db0b9d..8305815 100644 --- a/frontend/src/app/components/admin/admin-panel.component.ts +++ b/frontend/src/app/components/admin/admin-panel.component.ts @@ -6,6 +6,7 @@ import { PrivilegDashboardComponent } from "./privileg-dashboard/privileg-dashbo import { ChannelInfoFormComponent } from "../channel/channel-info-form/channel-info-form.component"; import { ReportsComponent } from "./reports/reports.component"; import { StatisticsComponent } from "./statistics/statistics.component"; +import { MagnetAdsComponent } from "./magnet-ads/magnet-ads.component"; @Component({ selector: 'admin-dashboard', @@ -19,7 +20,8 @@ import { StatisticsComponent } from "./statistics/statistics.component"; PrivilegDashboardComponent, ChannelInfoFormComponent, ReportsComponent, - StatisticsComponent + StatisticsComponent, + MagnetAdsComponent ], templateUrl: './admin-panel.component.html', styleUrls: ['./admin-panel.component.scss'] @@ -34,6 +36,7 @@ export class AdminPanelComponent implements OnInit { readonly closedReports = "closed-reports"; readonly allReports = "all-reports"; readonly statistics = "statistics"; + readonly magnetAds = "magnet-ads"; selectedMenuItem = this.info; @@ -59,6 +62,10 @@ export class AdminPanelComponent implements OnInit { title: "אימוג'ים", icon: 'smiling-face-outline', }, + { + title: 'שילוב פרסומות ממגנט', + icon: 'pricetags-outline', + }, { title: 'דיווחים', icon: 'alert-triangle-outline', @@ -117,6 +124,9 @@ export class AdminPanelComponent implements OnInit { case 'bar-chart-outline': this.selectedMenuItem = this.statistics; break; + case 'pricetags-outline': + this.selectedMenuItem = this.magnetAds; + break; } }); } diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html new file mode 100644 index 0000000..cdce902 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.html @@ -0,0 +1,230 @@ +
+ + + + + שילוב פרסומות ממגנט + + + +

+ מגנט היא פלטפורמת פרסומות חיצונית. ניתן להדביק כאן את קוד ההטמעה (HTML/JS) שקיבלתם + ממגנט, והוא יוצג בין ההודעות בערוץ. ההטמעה היא בצד הגולש בלבד - הפרסומת תיטען רק + כשהגולש גולל ומגיע אליה, וכל פעם נקראת מחדש כך שהתוכן עשוי להשתנות בכל גלילה. + אם אין תשובת שרת מהפרסום - לא יוצג כלום. +

+ +
+ + הפעלת שילוב פרסומות מגנט + +

כיבוי יסיר לחלוטין את הצגת הפרסומות בערוץ.

+
+ +
+ +

+ הדביקו את קוד ההטמעה שקיבלתם ממגנט. הקוד יוטמע כפי שהוא בכל מיקום פרסומת בערוץ + (תגיות <script> ירוצו בכל הצגה). +

+ +
+
+
+ + + + + מפתח API למגנט + + +
+ +

+ מפתח שניתן לכם ע"י מגנט. נדרש לצפייה בנתוני הקלקות ותגמולים. שמירת ההגדרות + תשמור גם את המפתח. המפתח לא נחשף לצד הלקוח - הקריאה למגנט מתבצעת מהשרת. +

+ +
+
+
+ + + + + תדירות הצגה + + + +

בחרו את שיטת התזמון להצגת פרסומות בין ההודעות.

+ +
+ + הצגה לפי כמות הודעות + + + הצגה לפי זמן + +
+ + @if (mode === 'by_messages') { +
+
+ +

בכל X הודעות תוצג פרסומת אחת. לדוגמא: ערך 5 = פרסומת אחרי כל 5 הודעות.

+ +
+ +
+ +

+ גם אם עברו X הודעות, אם הפרסומת הקודמת הוצגה לפני פחות מהזמן הזה - לא תוצג שוב. + הזינו 0 (או השאירו ריק) כדי לבטל את ההחרגה. +

+ +
+
+ } + + @if (mode === 'by_time') { +
+
+ +

פרסומת תוצג בערוץ אחת לכל פרק זמן. לדוגמא: ערך 60 = פרסומת בכל דקה.

+ +
+ +
+ +

+ גם אם עבר הזמן, אם לא נוספו לפחות X הודעות חדשות מאז הפרסומת הקודמת - לא תוצג שוב. + הזינו 0 (או השאירו ריק) כדי לבטל את ההחרגה. +

+ +
+
+ } +
+
+ + + + + נתוני הקלקות ותגמולים + + + +

+ נתונים אלו מתקבלים ישירות ממגנט בזמן אמת לפי המפתח שמוגדר למעלה. + רק הקלקות מאושרות לתשלום נספרות. הסכומים נטו, ללא מע"מ, בש"ח. + "היום" מתחיל מ-00:00 שעון ישראל. "שבוע"/"חודש" = 7/30 ימים אחורה. + מגנט שומר תוצאות במטמון לכ-30 שניות. +

+ +
+ @if (!stats && !statsError) { + + } @else { + + @if (statsLoadedAt) { + עודכן: {{ statsLoadedAt | date:'HH:mm:ss' }} + } + } +
+ + @if (statsError) { +
+ + {{ statsError }} +
+ } + + @if (stats && !statsError) { + @if (stats.site?.domain) { +
+ אתר: + {{ stats.site?.domain }} +
+ } + +
+
+
+ + הקלקות +
+
+ היום + {{ formatNumber(stats.clicks?.today) }} +
+
+ שבוע אחרון + {{ formatNumber(stats.clicks?.week) }} +
+
+ חודש אחרון + {{ formatNumber(stats.clicks?.month) }} +
+
+ +
+
+ + תגמולים ({{ stats.currency || 'ILS' }} - ללא מע"מ) +
+
+ היום + {{ formatMoney(stats.earnings?.today, stats.currency) }} +
+
+ שבוע אחרון + {{ formatMoney(stats.earnings?.week, stats.currency) }} +
+
+ חודש אחרון + {{ formatMoney(stats.earnings?.month, stats.currency) }} +
+
+
+ } +
+
+ +
+ +
+ +
diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss new file mode 100644 index 0000000..298c082 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.scss @@ -0,0 +1,196 @@ +:host { + display: block; + direction: rtl; +} + +.magnet-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 5rem; +} + +.card-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + + nb-icon { + font-size: 1.25rem; + } +} + +.intro { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.5; +} + +.setting-block { + margin-bottom: 1.25rem; + + &:last-child { + margin-bottom: 0; + } + + .bold { + font-weight: 600; + display: block; + margin-bottom: 0.25rem; + } + + .hint { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.4; + } + + textarea { + font-family: monospace; + font-size: 0.85rem; + } + + input[type="number"] { + max-width: 200px; + } +} + +.mode-options { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--divider-color, #edf1f7); +} + +.mode-section { + padding-top: 0.25rem; +} + +.stats-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } + + .stats-meta { + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + } +} + +.stats-error { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--background-basic-color-2, #f7f9fc); + border-inline-start: 3px solid var(--color-danger-default, #ff3d71); + color: var(--color-danger-default, #ff3d71); + border-radius: 0.4rem; + font-size: 0.9rem; +} + +.stats-site { + margin-bottom: 1rem; + font-size: 0.95rem; + + .bold { + font-weight: 600; + margin-inline-end: 0.4rem; + } + + a { + direction: ltr; + display: inline-block; + color: var(--color-primary-default); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.stats-section { + background: var(--background-basic-color-2, #f7f9fc); + padding: 1rem; + border-radius: 0.5rem; + + .stats-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + nb-icon { + font-size: 1.1rem; + } + + .stats-currency { + font-weight: 400; + font-size: 0.8rem; + color: var(--text-hint-color, #8f9bb3); + margin-inline-start: auto; + } + } + + .stats-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.4rem 0; + + & + .stats-row { + border-top: 1px dashed var(--divider-color, #edf1f7); + } + + .stats-label { + color: var(--text-hint-color, #8f9bb3); + font-size: 0.9rem; + } + + .stats-value { + font-weight: 600; + font-size: 1rem; + direction: ltr; + } + } +} + +.save-bar { + position: sticky; + bottom: 0; + background: var(--background-basic-color-1); + padding: 0.75rem 0; + border-top: 1px solid var(--divider-color, #edf1f7); + display: flex; + justify-content: flex-start; + z-index: 5; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } +} diff --git a/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts new file mode 100644 index 0000000..e0de604 --- /dev/null +++ b/frontend/src/app/components/admin/magnet-ads/magnet-ads.component.ts @@ -0,0 +1,209 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbRadioModule, + NbToastrService, + NbToggleModule, +} from '@nebular/theme'; +import { AdminService } from '../../../services/admin.service'; +import { Setting } from '../../../models/setting.model'; + +type MagnetMode = 'by_messages' | 'by_time'; + +interface MagnetStatsBucket { + today: number; + week: number; + month: number; +} + +interface MagnetStatsResponse { + site?: { id?: string; domain?: string }; + currency?: string; + clicks?: MagnetStatsBucket; + earnings?: MagnetStatsBucket; +} + +const MAGNET_KEYS = [ + 'magnet_enabled', + 'magnet_snippet', + 'magnet_mode', + 'magnet_per_messages', + 'magnet_min_time_seconds', + 'magnet_per_seconds', + 'magnet_min_messages_since', + 'magnet_api_key', +] as const; + +@Component({ + selector: 'app-magnet-ads', + imports: [ + CommonModule, + FormsModule, + NbCardModule, + NbButtonModule, + NbIconModule, + NbInputModule, + NbToggleModule, + NbRadioModule, + ], + templateUrl: './magnet-ads.component.html', + styleUrl: './magnet-ads.component.scss', +}) +export class MagnetAdsComponent implements OnInit { + enabled = false; + snippet = ''; + mode: MagnetMode = 'by_messages'; + perMessages = 5; + minTimeSeconds = 0; + perSeconds = 60; + minMessagesSince = 0; + apiKey = ''; + + otherSettings: Setting[] = []; + inProgress = false; + + stats: MagnetStatsResponse | null = null; + statsLoading = false; + statsError = ''; + statsLoadedAt: Date | null = null; + + constructor( + private adminService: AdminService, + private toast: NbToastrService, + ) {} + + ngOnInit(): void { + this.adminService.getSettings().then(settings => this.load(settings || [])); + } + + private load(settings: Setting[]) { + this.otherSettings = []; + const known = new Set(MAGNET_KEYS); + + for (const s of settings) { + if (!known.has(s.key)) { + this.otherSettings.push(s); + continue; + } + const v = s.value; + switch (s.key) { + case 'magnet_enabled': + this.enabled = this.toBool(v); + break; + case 'magnet_snippet': + this.snippet = v == null ? '' : String(v); + break; + case 'magnet_mode': + this.mode = (String(v) === 'by_time' ? 'by_time' : 'by_messages'); + break; + case 'magnet_per_messages': + this.perMessages = this.toInt(v, 5); + break; + case 'magnet_min_time_seconds': + this.minTimeSeconds = this.toInt(v, 0); + break; + case 'magnet_per_seconds': + this.perSeconds = this.toInt(v, 60); + break; + case 'magnet_min_messages_since': + this.minMessagesSince = this.toInt(v, 0); + break; + case 'magnet_api_key': + this.apiKey = v == null ? '' : String(v); + break; + } + } + } + + private toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v !== 0; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; + } + + private toInt(v: any, fallback: number): number { + if (v === null || v === undefined || v === '') return fallback; + const n = parseInt(String(v), 10); + return isNaN(n) ? fallback : n; + } + + save() { + this.inProgress = true; + const out: Setting[] = [...this.otherSettings]; + + if (this.enabled) out.push({ key: 'magnet_enabled', value: '1' as any }); + if (this.snippet?.trim()) out.push({ key: 'magnet_snippet', value: this.snippet as any }); + out.push({ key: 'magnet_mode', value: this.mode as any }); + + if (this.mode === 'by_messages') { + if (this.perMessages > 0) out.push({ key: 'magnet_per_messages', value: String(this.perMessages) as any }); + if (this.minTimeSeconds > 0) out.push({ key: 'magnet_min_time_seconds', value: String(this.minTimeSeconds) as any }); + } else { + if (this.perSeconds > 0) out.push({ key: 'magnet_per_seconds', value: String(this.perSeconds) as any }); + if (this.minMessagesSince > 0) out.push({ key: 'magnet_min_messages_since', value: String(this.minMessagesSince) as any }); + } + + if (this.apiKey?.trim()) out.push({ key: 'magnet_api_key', value: this.apiKey.trim() as any }); + + this.adminService.setSettings(out) + .then(() => this.toast.success('', 'הגדרות מגנט נשמרו בהצלחה')) + .catch(() => this.toast.danger('', 'שגיאה בשמירת ההגדרות')) + .finally(() => this.inProgress = false); + } + + async loadStats() { + this.statsLoading = true; + this.statsError = ''; + + try { + const res = await fetch('/api/admin/magnet/stats', { credentials: 'include' }); + const text = await res.text(); + let data: any = null; + try { data = text ? JSON.parse(text) : null; } catch {} + + if (!res.ok) { + if (res.status === 400) { + this.statsError = data?.message || 'מפתח API לא תקין או חסר. שמרו תחילה מפתח תקין ונסו שוב.'; + } else if (res.status === 404) { + this.statsError = 'האתר לא נמצא במערכת מגנט או שאינו מאושר.'; + } else if (res.status === 401 || res.status === 403) { + this.statsError = 'אין הרשאה לגשת לנתוני מגנט.'; + } else { + this.statsError = data?.message || `שגיאה בקבלת נתונים (HTTP ${res.status})`; + } + this.stats = null; + return; + } + + this.stats = data as MagnetStatsResponse; + this.statsLoadedAt = new Date(); + } catch { + this.statsError = 'שגיאת רשת בקריאה לשרת מגנט'; + this.stats = null; + } finally { + this.statsLoading = false; + } + } + + formatMoney(n: number | undefined, currency: string | undefined): string { + if (n === null || n === undefined) return '-'; + const code = (currency || 'ILS').toUpperCase(); + const symbol = code === 'ILS' ? '₪' : code === 'USD' ? '$' : code === 'EUR' ? '€' : code + ' '; + const formatted = Number(n).toLocaleString('he-IL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + return code === 'ILS' ? `${formatted} ${symbol}` : `${symbol}${formatted}`; + } + + formatNumber(n: number | undefined): string { + if (n === null || n === undefined) return '-'; + return Number(n).toLocaleString('he-IL'); + } +} diff --git a/frontend/src/app/components/admin/settings/settings.component.html b/frontend/src/app/components/admin/settings/settings.component.html index 80f3eea..0f0817a 100644 --- a/frontend/src/app/components/admin/settings/settings.component.html +++ b/frontend/src/app/components/admin/settings/settings.component.html @@ -1,31 +1,193 @@ - - - הגדרות ערוץ - - - - @for (setting of settings; track setting; let i = $index) { -
- - - -
- } -
- +
+
+ } + + @for (field of cat.fields; track field.key) { + @if (isFieldVisible(field)) { +
+ + @if (field.type === 'boolean') { +
+ + {{ field.label }} + +
+ @if (field.description) { +

{{ field.description }}

+ } + } @else { + + @if (field.description) { +

{{ field.description }}

+ } + + @switch (field.type) { + @case ('textarea') { + + } + @case ('number') { + + } + @case ('password') { + + } + @default { + + } + } + } +
+ } + } +
+
+ } + + + + + החלפות טקסט אוטומטיות + + +

+ ניתן להגדיר ביטויים רגולריים (Regex) שיוחלפו אוטומטית בעת פרסום הודעות חדשות. + בשדה "תבנית" יש להזין את הביטוי הרגולרי, ובשדה "החלפה" את הטקסט החלופי. + לדוגמא: תבנית (.*?\!)(.*) והחלפה **$1**$2 תדגיש כותרות הודעה. +

+ + @if (regexRules.length === 0) { +

לא הוגדרו כללי החלפה.

+ } + + @for (rule of regexRules; track $index; let i = $index) { +
+
+
+ + +
+
+ + +
+
+ +
+ } + + +
+
+ + + + + + הגדרות מתקדמות / מותאמות אישית + + +

+ אזור זה מאפשר להוסיף הגדרות שאינן מופיעות בטופס למעלה (לדוגמא: הגדרות חדשות + שטרם נוספו לממשק). יש להזין שם הגדרה וערך באופן ידני. +

- - + @if (extraSettings.length === 0) { +

אין הגדרות מותאמות אישית.

+ } + + @for (s of extraSettings; track $index; let i = $index) { +
+ + + +
+ } + + +
+
+
- - - - \ No newline at end of file + + diff --git a/frontend/src/app/components/admin/settings/settings.component.scss b/frontend/src/app/components/admin/settings/settings.component.scss index e69de29..819ebcb 100644 --- a/frontend/src/app/components/admin/settings/settings.component.scss +++ b/frontend/src/app/components/admin/settings/settings.component.scss @@ -0,0 +1,191 @@ +:host { + display: block; + direction: rtl; +} + +.settings-page { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 5rem; +} + +.settings-card { + margin-bottom: 0 !important; +} + +.settings-card-header { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + + nb-icon { + font-size: 1.25rem; + } +} + +.category-description { + margin: 0 0 1rem; + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.5; + + code { + background: var(--background-basic-color-2, #f7f9fc); + padding: 0.1rem 0.3rem; + border-radius: 4px; + font-size: 0.85em; + direction: ltr; + display: inline-block; + } +} + +.setting-field { + padding: 0.85rem 0; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + &:last-child { + border-bottom: none; + } + + &.is-toggle { + padding: 0.65rem 0; + } +} + +.field-label { + display: block; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: 0.25rem; + color: var(--text-basic-color); + + &.small { + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.2rem; + } +} + +.field-description { + margin: 0 0 0.5rem; + font-size: 0.82rem; + color: var(--text-hint-color, #8f9bb3); + line-height: 1.4; +} + +.toggle-row { + display: flex; + align-items: center; + + ::ng-deep nb-toggle .toggle-label { + margin-inline-start: 0.5rem; + } +} + +.toggle-description { + margin-top: 0.35rem; + margin-inline-start: 3.25rem; +} + +.fcm-json-paste { + background: var(--background-basic-color-2, #f7f9fc); + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + + textarea { + direction: ltr; + font-family: monospace; + font-size: 0.85rem; + } + + .error-text { + color: var(--color-danger-default, #ff3d71); + font-size: 0.85rem; + margin-top: 0.4rem; + } +} + +.regex-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; + padding: 0.6rem 0; + border-bottom: 1px solid var(--divider-color, #edf1f7); + + &:last-of-type { + border-bottom: none; + margin-bottom: 0.6rem; + } +} + +.regex-fields { + display: flex; + gap: 0.5rem; + flex: 1; + flex-wrap: wrap; + + .regex-field { + flex: 1; + min-width: 180px; + } +} + +.extra-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; + + input:first-of-type { + flex: 0 0 30%; + } +} + +.empty-state { + font-size: 0.9rem; + color: var(--text-hint-color, #8f9bb3); + font-style: italic; + margin: 0 0 0.75rem; +} + +.advanced-accordion { + ::ng-deep nb-accordion-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .advanced-title { + font-weight: 600; + } +} + +.save-bar { + position: sticky; + bottom: 0; + background: var(--background-basic-color-1); + padding: 0.75rem 0; + border-top: 1px solid var(--divider-color, #edf1f7); + display: flex; + justify-content: flex-start; + z-index: 5; + + button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + } +} + +hr { + border: none; + border-top: 1px solid var(--divider-color, #edf1f7); + margin: 1rem 0; +} + +.mt-2 { + margin-top: 0.5rem; +} diff --git a/frontend/src/app/components/admin/settings/settings.component.ts b/frontend/src/app/components/admin/settings/settings.component.ts index 5191a11..4bf219f 100644 --- a/frontend/src/app/components/admin/settings/settings.component.ts +++ b/frontend/src/app/components/admin/settings/settings.component.ts @@ -1,26 +1,63 @@ import { Component, OnInit } from '@angular/core'; import { AdminService } from '../../../services/admin.service'; -import { NbButtonModule, NbCardModule, NbToastrService, NbIconModule, NbInputModule } from "@nebular/theme"; +import { + NbButtonModule, + NbCardModule, + NbToastrService, + NbIconModule, + NbInputModule, + NbToggleModule, + NbAccordionModule, + NbTooltipModule, +} from "@nebular/theme"; import { FormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; import { Setting } from '../../../models/setting.model'; +import { + SETTINGS_SCHEMA, + SettingsCategorySchema, + SettingFieldSchema, + FCM_JSON_KEY_MAP, + getAllKnownKeys, +} from './settings.schema'; + +interface RegexRule { + pattern: string; + replace: string; +} + +interface ExtraSetting { + key: string; + value: string; +} @Component({ selector: 'app-settings', imports: [ + CommonModule, NbCardModule, NbButtonModule, NbIconModule, NbInputModule, - FormsModule -], + NbToggleModule, + NbAccordionModule, + NbTooltipModule, + FormsModule, + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss' }) export class SettingsComponent implements OnInit { - settings: Setting[] = []; + schema: SettingsCategorySchema[] = SETTINGS_SCHEMA; + values: Record = {}; + regexRules: RegexRule[] = []; + extraSettings: ExtraSetting[] = []; + setInProgress: boolean = false; + fcmJsonPaste: string = ''; + fcmJsonError: string = ''; constructor( private adminService: AdminService, @@ -29,19 +66,161 @@ export class SettingsComponent implements OnInit { ngOnInit(): void { this.adminService.getSettings() - .then(settings => this.settings = settings || []); + .then(settings => this.loadFromSettings(settings || [])); } - saveSettings() { - this.setInProgress = true; - this.adminService.setSettings(this.settings) - .then(() => this.tostService.success('', 'השינוים נשמרו בהצלחה!')) - .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינוים')); - this.setInProgress = false; + private loadFromSettings(settings: Setting[]) { + const known = getAllKnownKeys(); + this.values = {}; + this.regexRules = []; + this.extraSettings = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + if (f.type === 'boolean') this.values[f.key] = false; + else this.values[f.key] = ''; + } + } + + for (const s of settings) { + if (s.key === 'regex-replace') { + const raw = String(s.value ?? ''); + if (raw.includes('#')) { + const idx = raw.indexOf('#'); + this.regexRules.push({ + pattern: raw.substring(0, idx), + replace: raw.substring(idx + 1), + }); + } else if (raw) { + this.regexRules.push({ pattern: raw, replace: '' }); + } + continue; + } + + if (known.has(s.key)) { + const field = this.findField(s.key); + if (field?.type === 'boolean') { + this.values[s.key] = this.toBool(s.value); + } else { + this.values[s.key] = s.value === undefined || s.value === null ? '' : String(s.value); + } + } else { + this.extraSettings.push({ + key: s.key, + value: s.value === undefined || s.value === null ? '' : String(s.value), + }); + } + } } - removeSetting(index: number) { - // if (!confirm('האם אתה בטוח שברצונך למחוק את ההגדרה הזו?')) return; - this.settings.splice(index, 1); + private findField(key: string): SettingFieldSchema | undefined { + for (const cat of this.schema) { + const f = cat.fields.find(x => x.key === key); + if (f) return f; + } + return undefined; + } + + private toBool(v: any): boolean { + if (typeof v === 'boolean') return v; + if (typeof v === 'number') return v !== 0; + if (typeof v === 'string') { + const s = v.toLowerCase().trim(); + return s === '1' || s === 'true' || s === 'yes' || s === 'on'; + } + return false; + } + + isFieldVisible(field: SettingFieldSchema): boolean { + if (!field.hideWhen) return true; + return this.values[field.hideWhen.key] !== field.hideWhen.equals; + } + + addRegexRule() { + this.regexRules.push({ pattern: '', replace: '' }); + } + + removeRegexRule(i: number) { + this.regexRules.splice(i, 1); + } + + addExtraSetting() { + this.extraSettings.push({ key: '', value: '' }); + } + + removeExtraSetting(i: number) { + this.extraSettings.splice(i, 1); + } + + applyFcmJsonPaste() { + this.fcmJsonError = ''; + if (!this.fcmJsonPaste.trim()) return; + let parsed: any; + try { + parsed = JSON.parse(this.fcmJsonPaste); + } catch (e) { + this.fcmJsonError = 'JSON לא תקין'; + return; + } + + let count = 0; + for (const [jsonKey, settingKey] of Object.entries(FCM_JSON_KEY_MAP)) { + if (parsed[jsonKey] !== undefined) { + this.values[settingKey] = String(parsed[jsonKey]); + count++; + } + } + + if (count === 0) { + this.fcmJsonError = 'לא נמצאו שדות מוכרים בקובץ ה-JSON'; + return; + } + + this.fcmJsonPaste = ''; + this.tostService.success('', `${count} שדות מולאו אוטומטית מה-JSON`); + } + + private buildSettingsArray(): Setting[] { + const out: Setting[] = []; + + for (const cat of this.schema) { + for (const f of cat.fields) { + const v = this.values[f.key]; + if (f.type === 'boolean') { + if (v === true) out.push({ key: f.key, value: '1' as any }); + } else if (f.type === 'number') { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } else { + if (v !== '' && v !== null && v !== undefined) { + out.push({ key: f.key, value: String(v) as any }); + } + } + } + } + + for (const r of this.regexRules) { + const p = (r.pattern || '').trim(); + if (!p) continue; + out.push({ key: 'regex-replace', value: `${p}#${r.replace ?? ''}` as any }); + } + + for (const e of this.extraSettings) { + const k = (e.key || '').trim(); + if (!k) continue; + out.push({ key: k, value: e.value ?? '' as any }); + } + + return out; + } + + saveSettings() { + this.setInProgress = true; + const payload = this.buildSettingsArray(); + this.adminService.setSettings(payload) + .then(() => this.tostService.success('', 'השינויים נשמרו בהצלחה!')) + .catch(() => this.tostService.danger('', 'שגיאה בשמירת השינויים')) + .finally(() => this.setInProgress = false); } } diff --git a/frontend/src/app/components/admin/settings/settings.schema.ts b/frontend/src/app/components/admin/settings/settings.schema.ts new file mode 100644 index 0000000..c2e2c82 --- /dev/null +++ b/frontend/src/app/components/admin/settings/settings.schema.ts @@ -0,0 +1,323 @@ +export type SettingFieldType = 'boolean' | 'text' | 'number' | 'url' | 'textarea' | 'password'; + +export interface SettingFieldSchema { + key: string; + label: string; + description?: string; + type: SettingFieldType; + placeholder?: string; + default?: string | number | boolean; + hideWhen?: { key: string; equals: any }; +} + +export interface SettingsCategorySchema { + id: string; + title: string; + icon?: string; + description?: string; + fields: SettingFieldSchema[]; +} + +export const SETTINGS_SCHEMA: SettingsCategorySchema[] = [ + { + id: 'general', + title: 'הגדרות כלליות', + icon: 'settings-2-outline', + fields: [ + { + key: 'custom_title', + label: 'כותרת מותאמת אישית', + description: 'כותרת שתשמש לקידום האתר בתוצאות חיפוש (SEO).', + type: 'text', + placeholder: 'לדוגמא: הערוץ החדשותי שלי', + }, + { + key: 'contact_us', + label: 'קישור ליצירת קשר', + description: 'הזנת קישור תפעיל כפתור "צור קשר" שיפנה לקישור זה.', + type: 'url', + placeholder: 'https://example.com/contact', + }, + { + key: 'max_file_size', + label: 'הגבלת גודל קובץ להעלאה (MB)', + description: 'גודל מקסימלי בקבצים שניתן להעלות לערוץ. ברירת מחדל: 100 MB.', + type: 'number', + placeholder: '100', + default: 100, + }, + ], + }, + { + id: 'analytics', + title: 'אנליטיקס וטראקינג', + icon: 'activity-outline', + description: 'קוד שיוטמע בתוך תגית בכל עמוד באתר. מתאים לתגיות Google Analytics, Google Tag Manager, Meta Pixel וכדומה.', + fields: [ + { + key: 'analytics_head', + label: 'קוד אנליטיקס להטמעה ב-', + description: 'הדביקו כאן את כל קטע ה-HTML/JS שקיבלתם משירות האנליטיקס (כולל תגיות \n', + }, + ], + }, + { + id: 'auth', + title: 'הזדהות ואבטחה', + icon: 'shield-outline', + fields: [ + { + key: 'require_auth', + label: 'חיוב הזדהות לכניסה לערוץ', + description: 'משתמשים יחויבו להתחבר לפני שיוכלו לצפות בערוץ.', + type: 'boolean', + }, + { + key: 'require_auth_for_view_files', + label: 'חיוב הזדהות לצפייה בתמונות וסרטונים', + description: 'גם אם הערוץ פתוח לצפייה, ניתן לחייב הזדהות לפני צפייה בקבצים.', + type: 'boolean', + }, + { + key: 'api_secret_key', + label: 'מפתח API ליבוא הודעות', + description: 'מפתח סודי שיש לכלול בכותרת X-API-Key בעת קריאות יבוא הודעות.', + type: 'password', + placeholder: 'מפתח סודי חזק', + }, + ], + }, + { + id: 'views', + title: 'מונה צפיות', + icon: 'eye-outline', + fields: [ + { + key: 'count_views', + label: 'הפעלת ספירת צפיות בהודעות', + description: 'מציג ליד כל הודעה את מספר הצפיות שנספרו עבורה.', + type: 'boolean', + }, + ], + }, + { + id: 'ads', + title: 'פרסומות', + icon: 'pricetags-outline', + fields: [ + { + key: 'ad-iframe-src', + label: 'קישור HTML של פרסומת להטמעה', + description: 'הכנסת קישור תפעיל הצגת מסגרת פרסומת בערוץ.', + type: 'url', + placeholder: 'https://ad.example.com/banner.html', + }, + { + key: 'ad-iframe-width', + label: 'רוחב חלון הפרסומת (פיקסלים)', + description: 'רוחב מומלץ: 300.', + type: 'number', + placeholder: '300', + }, + ], + }, + { + id: 'webhook', + title: 'וובהוק (Webhook)', + icon: 'link-2-outline', + description: 'שליחת התראה לשרת חיצוני בעת יצירה, עדכון או מחיקה של הודעות.', + fields: [ + { + key: 'webhook_url', + label: 'כתובת ה-Webhook', + description: 'כתובת ה-URL שאליה תישלח בקשת POST בעת שינוי בהודעות.', + type: 'url', + placeholder: 'https://example.com/webhook', + }, + { + key: 'webhook_verify_token', + label: 'טוקן אימות', + description: 'טוקן סודי שיישלח עם כל בקשה לאימות שהבקשה הגיעה ממערכת זו (מומלץ).', + type: 'password', + placeholder: 'your-secret-token', + }, + ], + }, + { + id: 'notifications', + title: 'התראות דחיפה (Push)', + icon: 'bell-outline', + description: 'מבוסס על שירות Firebase Cloud Messaging (FCM) של גוגל.', + fields: [ + { + key: 'on_notification', + label: 'הפעלת התראות דחיפה', + description: 'יש להפעיל ולהזין את כל הפרטים מ-Firebase שבהמשך.', + type: 'boolean', + }, + { + key: 'project_domain', + label: 'דומיין הפרויקט (להפניית לחיצה על התראה)', + description: 'כתובת ה-URL שאליה ינותב המשתמש כשילחץ על התראה.', + type: 'url', + placeholder: 'https://example.com', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'vapid', + label: 'מפתח VAPID', + description: 'Cloud Messaging > Web Push certificates > Key pair', + type: 'password', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_api_key', + label: 'apiKey', + description: 'General > SDK setup and configuration > apiKey', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_auth_domain', + label: 'authDomain', + description: 'General > SDK setup and configuration > authDomain', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_project_id', + label: 'projectId', + description: 'General > SDK setup and configuration > projectId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_storage_bucket', + label: 'storageBucket', + description: 'General > SDK setup and configuration > storageBucket', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_messaging_sender_id', + label: 'messagingSenderId', + description: 'General > SDK setup and configuration > messagingSenderId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_app_id', + label: 'appId', + description: 'General > SDK setup and configuration > appId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_measurement_id', + label: 'measurementId', + description: 'General > SDK setup and configuration > measurementId', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + ], + }, + { + id: 'fcm_json', + title: 'חשבון שירות FCM (Service Account)', + icon: 'file-text-outline', + description: 'שדות אלה מגיעים מקובץ ה-JSON שנוצר תחת serviceaccounts > Generate new private key. ניתן להדביק את כל קובץ ה-JSON בשדה הראשון לשם מילוי אוטומטי של כל השדות.', + fields: [ + { + key: 'fcm_json_type', + label: 'type', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_project_id', + label: 'project_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_private_key_id', + label: 'private_key_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_private_key', + label: 'private_key', + type: 'textarea', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_email', + label: 'client_email', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_id', + label: 'client_id', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_auth_uri', + label: 'auth_uri', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_token_uri', + label: 'token_uri', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_auth_provider_x509_cert_url', + label: 'auth_provider_x509_cert_url', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_client_x509_cert_url', + label: 'client_x509_cert_url', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + { + key: 'fcm_json_universe_domain', + label: 'universe_domain', + type: 'text', + hideWhen: { key: 'on_notification', equals: false }, + }, + ], + }, +]; + +export const FCM_JSON_KEY_MAP: Record = { + type: 'fcm_json_type', + project_id: 'fcm_json_project_id', + private_key_id: 'fcm_json_private_key_id', + private_key: 'fcm_json_private_key', + client_email: 'fcm_json_client_email', + client_id: 'fcm_json_client_id', + auth_uri: 'fcm_json_auth_uri', + token_uri: 'fcm_json_token_uri', + auth_provider_x509_cert_url: 'fcm_json_auth_provider_x509_cert_url', + client_x509_cert_url: 'fcm_json_client_x509_cert_url', + universe_domain: 'fcm_json_universe_domain', +}; + +export function getAllKnownKeys(): Set { + const keys = new Set(); + for (const cat of SETTINGS_SCHEMA) { + for (const f of cat.fields) keys.add(f.key); + } + keys.add('regex-replace'); + return keys; +} diff --git a/frontend/src/app/components/channel/chat/chat.component.html b/frontend/src/app/components/channel/chat/chat.component.html index 5ed82b7..4024d1c 100644 --- a/frontend/src/app/components/channel/chat/chat.component.html +++ b/frontend/src/app/components/channel/chat/chat.component.html @@ -34,6 +34,11 @@ } + @if (adSlotsAfter.has(message.id!)) { +
+ +
+ }
} diff --git a/frontend/src/app/components/channel/chat/chat.component.ts b/frontend/src/app/components/channel/chat/chat.component.ts index 5f6200d..32f391b 100644 --- a/frontend/src/app/components/channel/chat/chat.component.ts +++ b/frontend/src/app/components/channel/chat/chat.component.ts @@ -12,6 +12,7 @@ import { NbToastrService } from "@nebular/theme"; import { MessageComponent } from "./message/message.component"; +import { MagnetAdSlotComponent } from "./magnet-ad-slot/magnet-ad-slot.component"; import { firstValueFrom, interval } from 'rxjs'; import { ChatMessage, ChatService } from '../../../services/chat.service'; import { AuthService } from '../../../services/auth.service'; @@ -19,6 +20,7 @@ import { ActivatedRoute } from '@angular/router'; import { NotificationsService } from '../../../services/notifications.service'; import { User } from '../../../models/user.model'; import { AdminService } from '../../../services/admin.service'; +import { MagnetAdsService } from '../../../services/magnet-ads.service'; type LoadMsgOpt = { scrollDown?: boolean; @@ -45,7 +47,8 @@ type ScrollOpt = { NbButtonModule, NbListModule, NbBadgeModule, - MessageComponent + MessageComponent, + MagnetAdSlotComponent ], templateUrl: './chat.component.html', styleUrl: './chat.component.scss' @@ -53,6 +56,7 @@ type ScrollOpt = { export class ChatComponent implements OnInit, OnDestroy { private eventSource!: EventSource; messages: ChatMessage[] = []; + adSlotsAfter: Set = new Set(); scheduledMessages!: ChatMessage[]; hideScheduledMessages: boolean = false; userInfo?: User; @@ -73,6 +77,7 @@ export class ChatComponent implements OnInit, OnDestroy { private _adminService: AdminService, private toastrService: NbToastrService, private notificationService: NotificationsService, + private magnetAds: MagnetAdsService, private zone: NgZone, private router: ActivatedRoute, ) { @@ -131,6 +136,10 @@ export class ChatComponent implements OnInit, OnDestroy { ngOnInit() { this.chatService.getEmojisList(true); + this.magnetAds.loadSettings() + .catch(() => null) + .then(() => this.rebuildItems()); + this.initializeMessageListener(); this.keepAliveSSE(); @@ -223,6 +232,15 @@ export class ChatComponent implements OnInit, OnDestroy { clearInterval(this.subLastHeartbeat); } + private rebuildItems() { + try { + this.adSlotsAfter = this.magnetAds.computeAdSlots(this.messages); + } catch (e) { + console.error('computeAdSlots failed, ads will not be shown:', e); + this.adSlotsAfter = new Set(); + } + } + async keepAliveSSE() { clearInterval(this.subLastHeartbeat); this.subLastHeartbeat = interval(10000) @@ -313,6 +331,7 @@ export class ChatComponent implements OnInit, OnDestroy { this.hasOldMessages = response.length >= this.limit; } this.offset = Math.min(...this.messages.map(m => m.id!)); + this.rebuildItems(); setTimeout(() => { opt.messageId && this.scrollToId({ messageId: opt.messageId, smooth: false, mark: opt.mark }); }, 300); diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html new file mode 100644 index 0000000..3581101 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+
+ {{ chatService.channelInfo?.name || '' }} + פרסומת +
+
+
+
+
+
+
+
diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss new file mode 100644 index 0000000..30a043e --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.scss @@ -0,0 +1,25 @@ +:host { + display: block; + width: 100%; +} + +.magnet-ad-message { + &.collapsed { + display: none !important; + } +} + +.magnet-ad-card { + max-width: 80vw; +} + +.magnet-ad-host { + display: block; + width: 100%; + min-height: 1px; + + ::ng-deep .magnet-ad-content { + display: block; + width: 100%; + } +} diff --git a/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts new file mode 100644 index 0000000..5d6f301 --- /dev/null +++ b/frontend/src/app/components/channel/chat/magnet-ad-slot/magnet-ad-slot.component.ts @@ -0,0 +1,132 @@ +import { + AfterViewInit, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, +} from '@angular/core'; +import { NbUserModule } from '@nebular/theme'; +import { MagnetAdsService } from '../../../../services/magnet-ads.service'; +import { ChatService } from '../../../../services/chat.service'; + +@Component({ + selector: 'app-magnet-ad-slot', + standalone: true, + imports: [NbUserModule], + templateUrl: './magnet-ad-slot.component.html', + styleUrl: './magnet-ad-slot.component.scss', +}) +export class MagnetAdSlotComponent implements AfterViewInit, OnDestroy { + @Input() slotKey: string = ''; + + @ViewChild('host', { static: true }) hostRef!: ElementRef; + + private observer?: IntersectionObserver; + private rendered = false; + private collapseTimer?: any; + collapsed = false; + + constructor( + private magnet: MagnetAdsService, + public chatService: ChatService, + ) {} + + ngAfterViewInit(): void { + if (typeof IntersectionObserver === 'undefined') { + this.render(); + return; + } + + this.observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !this.rendered) { + this.render(); + this.observer?.disconnect(); + break; + } + } + }, + { rootMargin: '200px 0px' }, + ); + this.observer.observe(this.hostRef.nativeElement); + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + if (this.collapseTimer) clearTimeout(this.collapseTimer); + } + + private render(): void { + if (this.rendered) return; + this.rendered = true; + + const settings = this.magnet.getSettings(); + const snippet = settings?.snippet?.trim(); + if (!snippet) { + this.collapse(); + return; + } + + const host = this.hostRef.nativeElement; + host.innerHTML = ''; + + const container = document.createElement('div'); + container.className = 'magnet-ad-content'; + host.appendChild(container); + + try { + this.injectSnippet(container, snippet); + } catch { + this.collapse(); + return; + } + + this.collapseTimer = setTimeout(() => this.collapseIfEmpty(), 5000); + } + + private injectSnippet(container: HTMLElement, snippet: string): void { + const template = document.createElement('template'); + template.innerHTML = snippet; + + const fragment = template.content; + const scripts: HTMLScriptElement[] = []; + fragment.querySelectorAll('script').forEach((s) => { + scripts.push(s as HTMLScriptElement); + }); + + container.appendChild(fragment); + + for (const oldScript of scripts) { + const newScript = document.createElement('script'); + for (const attr of Array.from(oldScript.attributes)) { + newScript.setAttribute(attr.name, attr.value); + } + if (oldScript.textContent) newScript.textContent = oldScript.textContent; + oldScript.parentNode?.replaceChild(newScript, oldScript); + } + } + + private collapseIfEmpty(): void { + const host = this.hostRef.nativeElement; + const content = host.querySelector('.magnet-ad-content') as HTMLElement | null; + if (!content) { + this.collapse(); + return; + } + const hasMeaningfulContent = + content.children.length > 0 || (content.textContent?.trim().length ?? 0) > 0; + const hasHeight = content.offsetHeight > 0; + + if (!hasMeaningfulContent || !hasHeight) { + this.collapse(); + } + } + + private collapse(): void { + this.collapsed = true; + const host = this.hostRef.nativeElement; + host.innerHTML = ''; + } +} diff --git a/frontend/src/app/services/magnet-ads.service.ts b/frontend/src/app/services/magnet-ads.service.ts new file mode 100644 index 0000000..8fabdc2 --- /dev/null +++ b/frontend/src/app/services/magnet-ads.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@angular/core'; +import { ChatMessage } from './chat.service'; + +export interface MagnetSettings { + enabled: boolean; + snippet: string; + mode: 'by_messages' | 'by_time' | string; + perMessages: number; + minTimeSeconds: number; + perSeconds: number; + minMessagesSinceLast: number; +} + +@Injectable({ providedIn: 'root' }) +export class MagnetAdsService { + private settings: MagnetSettings | null = null; + private settingsPromise: Promise | null = null; + + loadSettings(force = false): Promise { + if (this.settings && !force) return Promise.resolve(this.settings); + if (this.settingsPromise && !force) return this.settingsPromise; + + this.settingsPromise = fetch('/api/ads/magnet') + .then(r => r.ok ? r.json() : null) + .then((data: MagnetSettings | null) => { + this.settings = data; + return data; + }) + .catch(() => null); + + return this.settingsPromise; + } + + getSettings(): MagnetSettings | null { + return this.settings; + } + + /** + * Compute which message IDs should have an ad slot rendered after them. + * Input: messages array as held in chat.component (newest at index 0). + * Output: a Set of message IDs. The chat template iterates messages and, + * for any message whose id is in this set, renders an + * directly below it. The list is rendered by a flex-column-reverse + * container so the slots appear visually after the message in chronological + * reading order. + */ + computeAdSlots(messages: ChatMessage[]): Set { + const result = new Set(); + if (!messages || messages.length === 0) return result; + + const s = this.settings; + if (!s || !s.enabled || !s.snippet?.trim()) return result; + + const chrono = [...messages].reverse(); + + if (s.mode === 'by_time') { + this.fillByTime(result, chrono, s); + } else { + this.fillByMessages(result, chrono, s); + } + + return result; + } + + private fillByMessages(out: Set, chrono: ChatMessage[], s: MagnetSettings): void { + const per = Math.max(1, s.perMessages || 5); + const minTime = Math.max(0, s.minTimeSeconds || 0); + + let countSinceLast = 0; + let lastAdTime: number | null = null; + + for (const m of chrono) { + countSinceLast++; + if (countSinceLast < per) continue; + + const msgTime = this.toEpoch(m.timestamp); + const enoughTimePassed = !minTime || lastAdTime === null || + (msgTime !== null && (msgTime - lastAdTime) >= minTime * 1000); + + if (enoughTimePassed && m.id !== undefined && m.id !== null) { + out.add(m.id); + countSinceLast = 0; + if (msgTime !== null) lastAdTime = msgTime; + } + } + } + + private fillByTime(out: Set, chrono: ChatMessage[], s: MagnetSettings): void { + const per = Math.max(1, s.perSeconds || 60); + const minMsgs = Math.max(0, s.minMessagesSinceLast || 0); + + let lastAdTime: number | null = null; + let msgsSinceLast = 0; + + for (const m of chrono) { + msgsSinceLast++; + + const msgTime = this.toEpoch(m.timestamp); + if (msgTime === null) continue; + + if (lastAdTime === null) { + lastAdTime = msgTime; + continue; + } + + const elapsed = (msgTime - lastAdTime) / 1000; + if (elapsed >= per && msgsSinceLast >= minMsgs && m.id !== undefined && m.id !== null) { + out.add(m.id); + lastAdTime = msgTime; + msgsSinceLast = 0; + } + } + } + + private toEpoch(ts: any): number | null { + if (!ts) return null; + if (ts instanceof Date) return ts.getTime(); + const t = new Date(ts).getTime(); + return isNaN(t) ? null : t; + } +}