Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 81 additions & 3 deletions SET.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

הקוד יוזרק על ידי השרת לתוך תגית `<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 של פרסומות לתוך רשימת הצ'אט נעשה ע"י תוספת בתוך ה-`<nb-list-item>` של ההודעה (לא יצירת `<nb-list-item>` שני בכל iteration), כדי לא לשבור את ה-content children של `<nb-list>` של Nebular. ה-component `chat.component` שומר על `Set<number>` של ה-IDs של ההודעות שאחריהן יש להציג פרסומת, ומחשב מחדש את ה-Set בכל שינוי ב-`messages` (טעינה ראשונית, scroll, SSE).

## ריכוז הגדרות בממשק ניהול
|setting |value | הסבר |
|---------------|------|------|
Expand All @@ -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|הפעלת כפתור צור קשר|
|`contact_us`|url|הפעלת כפתור צור קשר|
|`analytics_head`|`<script>...</script>`|קוד אנליטיקס שיוזרק לתוך תגית `<head>` של כל עמוד|
|`magnet_enabled`|`1`|הפעלת שילוב פרסומות ממגנט בערוץ|
|`magnet_snippet`|`<script>...</script>`|קוד הטמעה (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 לא נחשף לדפדפן.|
70 changes: 70 additions & 0 deletions backend/ads.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package main

import (
"encoding/json"
"io"
"net/http"
"net/url"
"time"
)

type AdsSettings struct {
Expand All @@ -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)
}
6 changes: 6 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
})
Expand Down Expand Up @@ -135,6 +137,10 @@ func serveSpaFile(w http.ResponseWriter, r *http.Request) {
content = bytes.ReplaceAll(content, []byte("<title></title>"), []byte(settingConfig.CustomTitle))
}

if settingConfig.AnalyticsHead != "" {
content = bytes.Replace(content, []byte("</head>"), []byte(settingConfig.AnalyticsHead+"</head>"), 1)
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(content)
}
36 changes: 36 additions & 0 deletions backend/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}

Expand Down
4 changes: 4 additions & 0 deletions captain-definition
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
3 changes: 3 additions & 0 deletions frontend/src/app/components/admin/admin-panel.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
@case (statistics) {
<app-statistics></app-statistics>
}
@case (magnetAds) {
<app-magnet-ads></app-magnet-ads>
}
}
</div>
</nb-card-body>
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/app/components/admin/admin-panel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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']
Expand All @@ -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;

Expand All @@ -59,6 +62,10 @@ export class AdminPanelComponent implements OnInit {
title: "אימוג'ים",
icon: 'smiling-face-outline',
},
{
title: 'שילוב פרסומות ממגנט',
icon: 'pricetags-outline',
},
{
title: 'דיווחים',
icon: 'alert-triangle-outline',
Expand Down Expand Up @@ -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;
}
});
}
Expand Down
Loading