nodebb-plugin-niki-loyalty 1.0.29 → 1.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/library.js +179 -153
- package/package.json +1 -1
- package/static/lib/client.js +162 -104
package/library.js
CHANGED
|
@@ -2,21 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
const db = require.main.require('./src/database');
|
|
4
4
|
const user = require.main.require('./src/user');
|
|
5
|
+
const posts = require.main.require('./src/posts');
|
|
5
6
|
const routeHelpers = require.main.require('./src/controllers/helpers');
|
|
6
7
|
const nconf = require.main.require('nconf');
|
|
7
8
|
|
|
8
9
|
const Plugin = {};
|
|
9
10
|
|
|
10
11
|
// =========================
|
|
11
|
-
//
|
|
12
|
+
// ⚙️ AYARLAR & KURALLAR (GAME LOGIC)
|
|
12
13
|
// =========================
|
|
13
14
|
const SETTINGS = {
|
|
14
|
-
|
|
15
|
-
dailyCap: 250,
|
|
16
|
-
// coffeeCost: 250, // (eski kodda vardı ama artık tier sistemi var; istersen kullanırız)
|
|
15
|
+
dailyCap: 28, // Günlük Maksimum Puan
|
|
17
16
|
};
|
|
18
17
|
|
|
19
|
-
//
|
|
18
|
+
// Puan Tablosu ve Limitleri
|
|
19
|
+
const ACTIONS = {
|
|
20
|
+
login: { points: 2, limit: 1, name: 'Günlük Giriş 👋' },
|
|
21
|
+
new_topic: { points: 7, limit: 1, name: 'Yeni Konu 📝' },
|
|
22
|
+
reply: { points: 3.5, limit: 2, name: 'Yorum Yazma 💬' },
|
|
23
|
+
read: { points: 1, limit: 8, name: 'Konu Okuma 👀' }, // Heartbeat ile çalışır
|
|
24
|
+
like_given: { points: 1, limit: 2, name: 'Beğeni Atma ❤️' },
|
|
25
|
+
like_taken: { points: 1, limit: 2, name: 'Beğeni Alma 🌟' }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Ödüller
|
|
20
29
|
const REWARDS = [
|
|
21
30
|
{ cost: 250, name: 'Ücretsiz Kahve ☕' },
|
|
22
31
|
{ cost: 180, name: '%60 İndirimli Kahve' },
|
|
@@ -24,43 +33,23 @@ const REWARDS = [
|
|
|
24
33
|
{ cost: 60, name: '1 Kurabiye 🍪' },
|
|
25
34
|
];
|
|
26
35
|
|
|
27
|
-
// TEST MODE (Set to true to bypass point checks)
|
|
28
36
|
const TEST_MODE_UNLIMITED = false;
|
|
29
37
|
|
|
30
38
|
// =========================
|
|
31
|
-
//
|
|
39
|
+
// 🛠 YARDIMCI FONKSİYONLAR
|
|
32
40
|
// =========================
|
|
33
41
|
function safeParseMaybeJson(x) {
|
|
34
42
|
if (x == null) return null;
|
|
35
43
|
if (typeof x === 'object') return x;
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
return JSON.parse(x);
|
|
39
|
-
} catch (e) {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
+
try { return JSON.parse(x); } catch (e) { return null; }
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
function safeStringify(obj) {
|
|
47
|
-
try {
|
|
48
|
-
return JSON.stringify(obj);
|
|
49
|
-
} catch (e) {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
48
|
+
try { return JSON.stringify(obj); } catch (e) { return null; }
|
|
52
49
|
}
|
|
53
50
|
|
|
54
|
-
// =========================
|
|
55
|
-
// LOGGING
|
|
56
|
-
// =========================
|
|
57
51
|
async function addUserLog(uid, type, amount, desc) {
|
|
58
|
-
const logEntry = {
|
|
59
|
-
ts: Date.now(),
|
|
60
|
-
type, // 'earn' | 'spend'
|
|
61
|
-
amt: amount,
|
|
62
|
-
txt: desc,
|
|
63
|
-
};
|
|
52
|
+
const logEntry = { ts: Date.now(), type, amt: amount, txt: desc };
|
|
64
53
|
const payload = safeStringify(logEntry);
|
|
65
54
|
if (!payload) return;
|
|
66
55
|
await db.listAppend(`niki:activity:${uid}`, payload);
|
|
@@ -68,13 +57,8 @@ async function addUserLog(uid, type, amount, desc) {
|
|
|
68
57
|
}
|
|
69
58
|
|
|
70
59
|
async function addKasaLog(staffUid, customerName, customerUid, rewardName, amount) {
|
|
71
|
-
const logEntry = {
|
|
72
|
-
ts: Date.now(),
|
|
73
|
-
staff: staffUid,
|
|
74
|
-
cust: customerName,
|
|
75
|
-
cuid: customerUid,
|
|
76
|
-
amt: amount,
|
|
77
|
-
reward: rewardName, // Store the specific reward name
|
|
60
|
+
const logEntry = {
|
|
61
|
+
ts: Date.now(), staff: staffUid, cust: customerName, cuid: customerUid, amt: amount, reward: rewardName
|
|
78
62
|
};
|
|
79
63
|
const payload = safeStringify(logEntry);
|
|
80
64
|
if (!payload) return;
|
|
@@ -82,61 +66,138 @@ async function addKasaLog(staffUid, customerName, customerUid, rewardName, amoun
|
|
|
82
66
|
await db.listTrim('niki:kasa:history', -100, -1);
|
|
83
67
|
}
|
|
84
68
|
|
|
69
|
+
// 🔥 MERKEZİ PUAN DAĞITIM FONKSİYONU 🔥
|
|
70
|
+
// Bütün puan işlemleri buradan geçer, limitleri kontrol eder.
|
|
71
|
+
async function awardDailyAction(uid, actionKey) {
|
|
72
|
+
try {
|
|
73
|
+
const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
|
|
74
|
+
const rule = ACTIONS[actionKey];
|
|
75
|
+
|
|
76
|
+
if (!rule) return;
|
|
77
|
+
|
|
78
|
+
// 1. Genel Günlük Limit Kontrolü (28 Puan)
|
|
79
|
+
const dailyScoreKey = `niki:daily:${uid}:${today}`;
|
|
80
|
+
const currentDailyScore = parseFloat((await db.getObjectField(dailyScoreKey, 'score')) || 0);
|
|
81
|
+
|
|
82
|
+
if (currentDailyScore >= SETTINGS.dailyCap) return; // Günlük limit doldu
|
|
83
|
+
|
|
84
|
+
// 2. Eylem Bazlı Limit Kontrolü (Örn: Max 2 Yorum)
|
|
85
|
+
const actionCountKey = `niki:daily:${uid}:${today}:counts`;
|
|
86
|
+
const currentActionCount = parseInt((await db.getObjectField(actionCountKey, actionKey)) || 0, 10);
|
|
87
|
+
|
|
88
|
+
if (currentActionCount >= rule.limit) return; // Bu eylemin hakkı doldu
|
|
89
|
+
|
|
90
|
+
// 3. Puan Ver ve Kaydet
|
|
91
|
+
let pointsToGive = rule.points;
|
|
92
|
+
|
|
93
|
+
// Eğer verilecek puan 28 sınırını aşıyorsa, sadece aradaki farkı ver
|
|
94
|
+
if (currentDailyScore + pointsToGive > SETTINGS.dailyCap) {
|
|
95
|
+
pointsToGive = SETTINGS.dailyCap - currentDailyScore;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (pointsToGive <= 0) return;
|
|
99
|
+
|
|
100
|
+
// DB Güncellemeleri
|
|
101
|
+
await user.incrementUserFieldBy(uid, 'niki_points', pointsToGive); // Cüzdan
|
|
102
|
+
await db.incrObjectFieldBy(dailyScoreKey, 'score', pointsToGive); // Günlük Toplam
|
|
103
|
+
await db.incrObjectFieldBy(actionCountKey, actionKey, 1); // Eylem Sayacı
|
|
104
|
+
|
|
105
|
+
// Logla
|
|
106
|
+
await addUserLog(uid, 'earn', pointsToGive, rule.name);
|
|
107
|
+
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(`[Niki-Loyalty] Error awarding points for ${actionKey}:`, err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
// =========================
|
|
115
|
+
// ⚓ HOOKS (Olay Dinleyicileri)
|
|
116
|
+
// =========================
|
|
117
|
+
|
|
118
|
+
// 1. GÜNLÜK GİRİŞ (Login)
|
|
119
|
+
Plugin.onLogin = async function (data) {
|
|
120
|
+
if (!data || !data.uid) return;
|
|
121
|
+
await awardDailyAction(data.uid, 'login');
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// 2. YENİ KONU AÇMA
|
|
125
|
+
Plugin.onTopicCreate = async function (data) {
|
|
126
|
+
// data.topic.uid konusuyu açan kişidir
|
|
127
|
+
if (!data || !data.topic || !data.topic.uid) return;
|
|
128
|
+
await awardDailyAction(data.topic.uid, 'new_topic');
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// 3. YORUM YAZMA (Reply)
|
|
132
|
+
Plugin.onPostCreate = async function (data) {
|
|
133
|
+
if (!data || !data.post || !data.post.uid) return;
|
|
134
|
+
|
|
135
|
+
// Eğer post "main" ise (yani konunun kendisi ise) yorum sayılmaz, konu sayılır.
|
|
136
|
+
// NodeBB'de isMain kontrolü:
|
|
137
|
+
const isMain = await posts.isMain(data.post.pid);
|
|
138
|
+
if (isMain) return; // Bunu TopicCreate zaten yakalıyor
|
|
139
|
+
|
|
140
|
+
await awardDailyAction(data.post.uid, 'reply');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// 4. BEĞENİ (Like Atma ve Alma)
|
|
144
|
+
Plugin.onUpvote = async function (data) {
|
|
145
|
+
// data = { post: { pid, uid, ... }, uid: <like atan>, ... }
|
|
146
|
+
// Like Atan Kazanır:
|
|
147
|
+
if (data.uid) {
|
|
148
|
+
await awardDailyAction(data.uid, 'like_given');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Like Alan Kazanır (Post sahibi):
|
|
152
|
+
if (data.post && data.post.uid && data.post.uid !== data.uid) {
|
|
153
|
+
// Kendine like atarsa kazanmasın
|
|
154
|
+
await awardDailyAction(data.post.uid, 'like_taken');
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
|
|
85
159
|
// =========================
|
|
86
|
-
//
|
|
160
|
+
// 🚀 INIT & ROUTES
|
|
87
161
|
// =========================
|
|
88
162
|
Plugin.init = async function (params) {
|
|
89
163
|
const router = params.router;
|
|
90
164
|
const middleware = params.middleware;
|
|
91
165
|
|
|
92
|
-
// 1) HEARTBEAT (
|
|
166
|
+
// 1) HEARTBEAT (Artık "Okuma" Puanı veriyor)
|
|
167
|
+
// Client-side script her 30-60 saniyede bir bu adrese istek atmalıdır.
|
|
93
168
|
router.post('/api/niki-loyalty/heartbeat', middleware.ensureLoggedIn, async (req, res) => {
|
|
94
169
|
try {
|
|
95
170
|
const uid = req.uid;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const currentDailyScore = parseInt((await db.getObjectField(dailyKey, 'score')) || 0, 10);
|
|
100
|
-
|
|
101
|
-
if (currentDailyScore >= SETTINGS.dailyCap) {
|
|
102
|
-
return res.json({ earned: false, reason: 'daily_cap' });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
await user.incrementUserFieldBy(uid, 'niki_points', SETTINGS.pointsPerHeartbeat);
|
|
106
|
-
await db.incrObjectFieldBy(dailyKey, 'score', SETTINGS.pointsPerHeartbeat);
|
|
107
|
-
|
|
108
|
-
// ✅ Log: Cüzdan geçmişinde "kazanım" görünsün
|
|
109
|
-
await addUserLog(uid, 'earn', SETTINGS.pointsPerHeartbeat, 'Ders Çalışma 📚');
|
|
110
|
-
|
|
171
|
+
// Heartbeat geldiğinde "read" aksiyonunu tetikle
|
|
172
|
+
await awardDailyAction(uid, 'read');
|
|
173
|
+
|
|
111
174
|
const newBalance = await user.getUserField(uid, 'niki_points');
|
|
112
|
-
return res.json({ earned: true,
|
|
175
|
+
return res.json({ earned: true, total: newBalance });
|
|
113
176
|
} catch (err) {
|
|
114
|
-
return res.status(500).json({
|
|
177
|
+
return res.status(500).json({ error: 'error' });
|
|
115
178
|
}
|
|
116
179
|
});
|
|
117
180
|
|
|
118
|
-
// 2) WALLET DATA (
|
|
181
|
+
// 2) WALLET DATA (Cüzdan Bilgileri)
|
|
119
182
|
router.get('/api/niki-loyalty/wallet-data', middleware.ensureLoggedIn, async (req, res) => {
|
|
120
183
|
try {
|
|
121
184
|
const uid = req.uid;
|
|
122
185
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
123
|
-
|
|
186
|
+
|
|
124
187
|
const [userData, dailyData, historyRaw] = await Promise.all([
|
|
125
188
|
user.getUserFields(uid, ['niki_points']),
|
|
126
189
|
db.getObject(`niki:daily:${uid}:${today}`),
|
|
127
190
|
db.getListRange(`niki:activity:${uid}`, 0, -1),
|
|
128
191
|
]);
|
|
129
192
|
|
|
130
|
-
const
|
|
131
|
-
const dailyScore = parseInt(dailyData?.score || 0, 10);
|
|
132
|
-
|
|
193
|
+
const dailyScore = parseFloat(dailyData?.score || 0);
|
|
133
194
|
let dailyPercent = (dailyScore / SETTINGS.dailyCap) * 100;
|
|
134
195
|
if (dailyPercent > 100) dailyPercent = 100;
|
|
135
196
|
|
|
136
197
|
const history = (historyRaw || []).map(safeParseMaybeJson).filter(Boolean).reverse();
|
|
137
198
|
|
|
138
199
|
return res.json({
|
|
139
|
-
points:
|
|
200
|
+
points: parseInt(userData?.niki_points || 0, 10),
|
|
140
201
|
dailyScore,
|
|
141
202
|
dailyCap: SETTINGS.dailyCap,
|
|
142
203
|
dailyPercent,
|
|
@@ -148,8 +209,9 @@ Plugin.init = async function (params) {
|
|
|
148
209
|
}
|
|
149
210
|
});
|
|
150
211
|
|
|
151
|
-
// 3) KASA HISTORY
|
|
212
|
+
// 3) KASA HISTORY
|
|
152
213
|
router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (req, res) => {
|
|
214
|
+
// ... (Mevcut kodunun aynısı - sadece yetki kontrolü var)
|
|
153
215
|
try {
|
|
154
216
|
const isAdmin = await user.isAdministrator(req.uid);
|
|
155
217
|
const isMod = await user.isGlobalModerator(req.uid);
|
|
@@ -157,121 +219,85 @@ Plugin.init = async function (params) {
|
|
|
157
219
|
|
|
158
220
|
const raw = await db.getListRange('niki:kasa:history', 0, -1);
|
|
159
221
|
const rows = (raw || []).map(safeParseMaybeJson).filter(Boolean).reverse();
|
|
160
|
-
|
|
222
|
+
|
|
223
|
+
// Kullanıcı detaylarını doldurma (Map logic)
|
|
161
224
|
const uids = rows.map(r => parseInt(r.cuid, 10)).filter(n => Number.isFinite(n) && n > 0);
|
|
162
225
|
const users = await user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture', 'icon:bgColor']);
|
|
163
226
|
const userMap = {};
|
|
164
|
-
(users || []).forEach(u =>
|
|
165
|
-
userMap[u.uid] = u;
|
|
166
|
-
});
|
|
227
|
+
(users || []).forEach(u => userMap[u.uid] = u);
|
|
167
228
|
|
|
168
229
|
const rp = nconf.get('relative_path') || '';
|
|
169
|
-
|
|
170
230
|
const enriched = rows.map(r => {
|
|
171
|
-
const
|
|
172
|
-
const u = userMap[uid];
|
|
173
|
-
if (!u) {
|
|
174
|
-
// yine de reward fallback yapalım
|
|
175
|
-
return { ...r, reward: r.reward || 'İşlem' };
|
|
176
|
-
}
|
|
231
|
+
const u = userMap[r.cuid] || {};
|
|
177
232
|
return {
|
|
178
233
|
...r,
|
|
179
234
|
cust: u.username || r.cust || 'Bilinmeyen',
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
iconBg: u['icon:bgColor'] || r.iconBg || '#4b5563',
|
|
235
|
+
picture: u.picture || '',
|
|
236
|
+
iconBg: u['icon:bgColor'] || '#4b5563',
|
|
183
237
|
profileUrl: u.userslug ? `${rp}/user/${u.userslug}` : '',
|
|
184
|
-
reward: r.reward || 'İşlem'
|
|
238
|
+
reward: r.reward || 'İşlem'
|
|
185
239
|
};
|
|
186
240
|
});
|
|
187
|
-
|
|
188
241
|
return res.json(enriched);
|
|
189
|
-
} catch (
|
|
190
|
-
return res.status(500).json([]);
|
|
191
|
-
}
|
|
242
|
+
} catch(e) { return res.status(500).json([]); }
|
|
192
243
|
});
|
|
193
244
|
|
|
194
|
-
// 4)
|
|
245
|
+
// 4) QR OLUŞTURMA
|
|
195
246
|
router.post('/api/niki-loyalty/generate-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
196
247
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const minCost = REWARDS[REWARDS.length - 1].cost;
|
|
201
|
-
|
|
202
|
-
if (!TEST_MODE_UNLIMITED && points < minCost) {
|
|
203
|
-
return res.json({ success: false, message: `En az ${minCost} puan gerekli.` });
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const token = Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
207
|
-
await db.set(`niki:qr:${token}`, uid);
|
|
208
|
-
await db.expire(`niki:qr:${token}`, 120);
|
|
248
|
+
const uid = req.uid;
|
|
249
|
+
const points = parseFloat((await user.getUserField(uid, 'niki_points')) || 0);
|
|
250
|
+
const minCost = REWARDS[REWARDS.length - 1].cost; // En ucuz ödül
|
|
209
251
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
252
|
+
if (!TEST_MODE_UNLIMITED && points < minCost) {
|
|
253
|
+
return res.json({ success: false, message: `Yetersiz Puan. En az ${minCost} gerekli.` });
|
|
254
|
+
}
|
|
255
|
+
const token = Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
256
|
+
await db.set(`niki:qr:${token}`, uid);
|
|
257
|
+
await db.expire(`niki:qr:${token}`, 120); // 2 dakika geçerli
|
|
258
|
+
return res.json({ success: true, token });
|
|
259
|
+
} catch(e) { return res.status(500).json({ success: false }); }
|
|
214
260
|
});
|
|
215
261
|
|
|
216
|
-
// 5)
|
|
262
|
+
// 5) QR TARATMA (Kasa İşlemi)
|
|
217
263
|
router.post('/api/niki-loyalty/scan-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (!
|
|
239
|
-
|
|
264
|
+
// ... (Mevcut kodunun aynısı)
|
|
265
|
+
try {
|
|
266
|
+
const token = req.body.token;
|
|
267
|
+
const isAdmin = await user.isAdministrator(req.uid);
|
|
268
|
+
const isMod = await user.isGlobalModerator(req.uid);
|
|
269
|
+
if (!isAdmin && !isMod) return res.status(403).json({success:false, message:'Yetkisiz'});
|
|
270
|
+
|
|
271
|
+
const custUid = await db.get(`niki:qr:${token}`);
|
|
272
|
+
if (!custUid) return res.json({success:false, message:'Geçersiz Kod'});
|
|
273
|
+
|
|
274
|
+
const pts = parseFloat(await user.getUserField(custUid, 'niki_points') || 0);
|
|
275
|
+
|
|
276
|
+
let selectedReward = null;
|
|
277
|
+
if (!TEST_MODE_UNLIMITED) {
|
|
278
|
+
for (const r of REWARDS) {
|
|
279
|
+
if (pts >= r.cost) { selectedReward = r; break; }
|
|
280
|
+
}
|
|
281
|
+
if (!selectedReward) return res.json({success:false, message:'Puan Yetersiz'});
|
|
282
|
+
} else { selectedReward = REWARDS[0]; }
|
|
283
|
+
|
|
284
|
+
if (!TEST_MODE_UNLIMITED) {
|
|
285
|
+
await user.decrementUserFieldBy(custUid, 'niki_points', selectedReward.cost);
|
|
240
286
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
await db.delete(`niki:qr:${token}`);
|
|
251
|
-
|
|
252
|
-
const customerData = await user.getUserFields(customerUid, ['username', 'picture', 'userslug']);
|
|
253
|
-
|
|
254
|
-
// Logs
|
|
255
|
-
await addUserLog(customerUid, 'spend', selectedReward.cost, selectedReward.name);
|
|
256
|
-
await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid, selectedReward.name, selectedReward.cost);
|
|
257
|
-
|
|
258
|
-
return res.json({
|
|
259
|
-
success: true,
|
|
260
|
-
customer: customerData,
|
|
261
|
-
rewardName: selectedReward.name,
|
|
262
|
-
cost: selectedReward.cost,
|
|
263
|
-
message: 'Onaylandı!',
|
|
264
|
-
});
|
|
265
|
-
} catch (err) {
|
|
266
|
-
return res.status(500).json({ success: false, message: 'Sunucu hatası' });
|
|
267
|
-
}
|
|
287
|
+
await db.delete(`niki:qr:${token}`);
|
|
288
|
+
|
|
289
|
+
const cData = await user.getUserFields(custUid, ['username', 'picture', 'userslug']);
|
|
290
|
+
await addUserLog(custUid, 'spend', selectedReward.cost, selectedReward.name);
|
|
291
|
+
await addKasaLog(req.uid, cData.username, custUid, selectedReward.name, selectedReward.cost);
|
|
292
|
+
|
|
293
|
+
return res.json({ success: true, customer: cData, rewardName: selectedReward.name, cost: selectedReward.cost });
|
|
294
|
+
} catch(e) { return res.status(500).json({success:false}); }
|
|
268
295
|
});
|
|
269
296
|
|
|
270
|
-
// 6)
|
|
297
|
+
// 6) SAYFA ROTALARI
|
|
271
298
|
routeHelpers.setupPageRoute(router, '/niki-kasa', middleware, [], async (req, res) => {
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
if (!isAdmin && !isMod) return res.render('403', {});
|
|
299
|
+
const isStaff = await user.isAdministrator(req.uid) || await user.isGlobalModerator(req.uid);
|
|
300
|
+
if (!isStaff) return res.render('403', {});
|
|
275
301
|
return res.render('niki-kasa', { title: 'Niki Kasa' });
|
|
276
302
|
});
|
|
277
303
|
};
|
|
@@ -292,4 +318,4 @@ Plugin.addNavigation = async function (nav) {
|
|
|
292
318
|
return nav;
|
|
293
319
|
};
|
|
294
320
|
|
|
295
|
-
module.exports = Plugin;
|
|
321
|
+
module.exports = Plugin;
|
package/package.json
CHANGED
package/static/lib/client.js
CHANGED
|
@@ -1,128 +1,186 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
/* globals $, app, socket, ajaxify, utils */
|
|
4
|
+
|
|
3
5
|
$(document).ready(function () {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
let heartbeatInterval = null;
|
|
7
|
+
|
|
8
|
+
// Puanları güzel göstermek için yardımcı fonksiyon (Örn: 10.0 -> 10, 10.5 -> 10.5)
|
|
9
|
+
function formatPoints(points) {
|
|
10
|
+
let val = parseFloat(points);
|
|
11
|
+
if (isNaN(val)) return '0';
|
|
12
|
+
// Eğer tam sayı ise virgülsüz, değilse 1 basamaklı göster
|
|
13
|
+
return Number.isInteger(val) ? val.toFixed(0) : val.toFixed(1);
|
|
14
|
+
}
|
|
7
15
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<span class="niki-lbl">PUANIM</span>
|
|
15
|
-
<span class="niki-val" id="niki-live-points">...</span>
|
|
16
|
-
</div>
|
|
17
|
-
</div>
|
|
18
|
-
</div>
|
|
19
|
-
`;
|
|
16
|
+
$(window).on('action:ajaxify.end', function (ev, data) {
|
|
17
|
+
// 1. ÖNCEKİ SAYAÇLARI TEMİZLE (Sayfa geçişlerinde üst üste binmesin)
|
|
18
|
+
if (heartbeatInterval) {
|
|
19
|
+
clearInterval(heartbeatInterval);
|
|
20
|
+
heartbeatInterval = null;
|
|
21
|
+
}
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
// ============================================================
|
|
24
|
+
// ❤️ KONU OKUMA (HEARTBEAT) SİSTEMİ
|
|
25
|
+
// ============================================================
|
|
26
|
+
// Sadece 'topic' (konu) sayfasındaysak sayaç çalışsın.
|
|
27
|
+
if (ajaxify.data.template.name === 'topic') {
|
|
28
|
+
console.log('[Niki-Loyalty] Konu sayfası algılandı, sayaç başlatılıyor...');
|
|
29
|
+
|
|
30
|
+
// 30 Saniyede bir tetikle (Günde 8 limit var backendde)
|
|
31
|
+
heartbeatInterval = setInterval(function () {
|
|
32
|
+
// Tarayıcı sekmesi aktif değilse gönderme (opsiyonel optimizasyon)
|
|
33
|
+
if (document.hidden) return;
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
$.post('/api/niki-loyalty/heartbeat', {}, function (response) {
|
|
36
|
+
if (response && response.earned) {
|
|
37
|
+
// Eğer puan kazandıysa ufak bir bildirim göster
|
|
38
|
+
app.alert({
|
|
39
|
+
type: 'success',
|
|
40
|
+
title: 'Puan Kazanıldı!',
|
|
41
|
+
message: 'Konu okuduğun için puan hesabına eklendi. 🐈',
|
|
42
|
+
timeout: 2500
|
|
43
|
+
});
|
|
44
|
+
console.log('[Niki-Loyalty] Heartbeat başarılı. Yeni Puan:', response.total);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}, 30000); // 30.000 ms = 30 Saniye
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
$('#niki-floating-widget').removeClass('niki-hidden');
|
|
50
|
+
// ============================================================
|
|
51
|
+
// 💰 CÜZDAN SAYFASI (niki-wallet)
|
|
52
|
+
// ============================================================
|
|
53
|
+
if (data.url === 'niki-wallet') {
|
|
54
|
+
loadWalletData();
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
//
|
|
39
|
-
|
|
57
|
+
// ============================================================
|
|
58
|
+
// 🏪 KASA SAYFASI (niki-kasa) - Yetkili İçin
|
|
59
|
+
// ============================================================
|
|
60
|
+
if (data.url === 'niki-kasa') {
|
|
61
|
+
loadKasaHistory(); // Geçmişi yükle
|
|
62
|
+
setupKasaScanner(); // QR okutma butonlarını ayarla
|
|
63
|
+
}
|
|
64
|
+
});
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
$('#
|
|
48
|
-
$('#
|
|
66
|
+
// -------------------------------------------------------------
|
|
67
|
+
// CÜZDAN FONKSİYONLARI
|
|
68
|
+
// -------------------------------------------------------------
|
|
69
|
+
function loadWalletData() {
|
|
70
|
+
$.get('/api/niki-loyalty/wallet-data', function (data) {
|
|
71
|
+
// Puanları yerleştir (Decimal desteği ile)
|
|
72
|
+
$('#user-points').text(formatPoints(data.points));
|
|
73
|
+
$('#daily-score').text(formatPoints(data.dailyScore));
|
|
74
|
+
$('#daily-cap').text(data.dailyCap);
|
|
49
75
|
|
|
50
|
-
//
|
|
51
|
-
|
|
76
|
+
// Progress Bar
|
|
77
|
+
const percent = data.dailyPercent > 100 ? 100 : data.dailyPercent;
|
|
78
|
+
$('#daily-progress').css('width', percent + '%').text(Math.round(percent) + '%');
|
|
79
|
+
|
|
80
|
+
// Geçmiş Tablosu
|
|
81
|
+
const historyList = $('#history-list');
|
|
82
|
+
historyList.empty();
|
|
52
83
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
84
|
+
if (data.history && data.history.length > 0) {
|
|
85
|
+
data.history.forEach(function (item) {
|
|
86
|
+
const colorClass = item.type === 'earn' ? 'text-success' : 'text-danger';
|
|
87
|
+
const sign = item.type === 'earn' ? '+' : '-';
|
|
88
|
+
const dateStr = new Date(item.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
89
|
+
|
|
90
|
+
const html = `
|
|
91
|
+
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
92
|
+
<div>
|
|
93
|
+
<small class="text-muted me-2">${dateStr}</small>
|
|
94
|
+
<span>${item.txt}</span>
|
|
95
|
+
</div>
|
|
96
|
+
<span class="fw-bold ${colorClass}">${sign}${formatPoints(item.amt)}</span>
|
|
97
|
+
</li>
|
|
98
|
+
`;
|
|
99
|
+
historyList.append(html);
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
historyList.append('<li class="list-group-item text-center text-muted">Henüz işlem yok.</li>');
|
|
60
103
|
}
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
104
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
105
|
+
// QR Oluştur Butonu
|
|
106
|
+
$('#btn-generate-qr').off('click').on('click', function () {
|
|
107
|
+
$(this).prop('disabled', true);
|
|
108
|
+
$.post('/api/niki-loyalty/generate-qr', {}, function (res) {
|
|
109
|
+
$('#btn-generate-qr').prop('disabled', false);
|
|
110
|
+
if (res.success) {
|
|
111
|
+
// Basit bir modal veya alert ile kodu göster (veya QR kütüphanesi kullan)
|
|
112
|
+
// Şimdilik token'ı text olarak gösteriyoruz:
|
|
113
|
+
app.alert({
|
|
114
|
+
type: 'info',
|
|
115
|
+
title: 'Kod Oluşturuldu',
|
|
116
|
+
message: '<div class="text-center">Kasiyere bu kodu göster:<br><h2 style="margin:10px 0; letter-spacing:2px;">' + res.token + '</h2><small>2 dakika geçerli</small></div>',
|
|
117
|
+
timeout: 10000 // 10 saniye ekranda kalsın
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
app.alert({ type: 'danger', message: res.message });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
70
125
|
}
|
|
71
126
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
setTimeout(fixLogo, 500); // 0.5sn sonra son bir kontrol
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// --- AKTİFLİK SİSTEMİ (Heartbeat) ---
|
|
82
|
-
let activeSeconds = 0;
|
|
83
|
-
let isUserActive = false;
|
|
84
|
-
let idleTimer;
|
|
85
|
-
|
|
86
|
-
function resetIdleTimer() {
|
|
87
|
-
isUserActive = true;
|
|
88
|
-
clearTimeout(idleTimer);
|
|
89
|
-
idleTimer = setTimeout(() => { isUserActive = false; }, 30000);
|
|
90
|
-
}
|
|
91
|
-
$(window).on('mousemove scroll keydown click touchstart', resetIdleTimer);
|
|
127
|
+
// -------------------------------------------------------------
|
|
128
|
+
// KASA FONKSİYONLARI (Admin/Mod)
|
|
129
|
+
// -------------------------------------------------------------
|
|
130
|
+
function loadKasaHistory() {
|
|
131
|
+
const tbody = $('#kasa-history-tbody');
|
|
132
|
+
if (tbody.length === 0) return;
|
|
92
133
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
sendHeartbeat();
|
|
99
|
-
activeSeconds = 0;
|
|
100
|
-
}
|
|
101
|
-
}, 1000);
|
|
102
|
-
|
|
103
|
-
function sendHeartbeat() {
|
|
104
|
-
$.post('/api/niki-loyalty/heartbeat', { _csrf: config.csrf_token }, function(res) {
|
|
105
|
-
if (res.earned) {
|
|
106
|
-
// Puanı güncelle
|
|
107
|
-
$('#niki-live-points').text(res.total);
|
|
108
|
-
// Hafızayı da güncelle
|
|
109
|
-
localStorage.setItem('niki_last_points', res.total);
|
|
110
|
-
|
|
111
|
-
showNikiToast(`+${res.points} Puan Kazandın! ☕`);
|
|
112
|
-
$('#niki-floating-widget').addClass('niki-bounce');
|
|
113
|
-
setTimeout(() => $('#niki-floating-widget').removeClass('niki-bounce'), 500);
|
|
134
|
+
$.get('/api/niki-loyalty/kasa-history', function (rows) {
|
|
135
|
+
tbody.empty();
|
|
136
|
+
if (!rows || rows.length === 0) {
|
|
137
|
+
tbody.append('<tr><td colspan="5" class="text-center">Geçmiş işlem yok.</td></tr>');
|
|
138
|
+
return;
|
|
114
139
|
}
|
|
140
|
+
rows.forEach(r => {
|
|
141
|
+
const dateStr = new Date(r.ts).toLocaleDateString() + ' ' + new Date(r.ts).toLocaleTimeString();
|
|
142
|
+
const rowHtml = `
|
|
143
|
+
<tr>
|
|
144
|
+
<td>${dateStr}</td>
|
|
145
|
+
<td>
|
|
146
|
+
<a href="${r.profileUrl}" target="_blank" class="text-decoration-none">
|
|
147
|
+
<span class="avatar avatar-sm" style="background-color: ${r.iconBg};">${r.cust.charAt(0).toUpperCase()}</span>
|
|
148
|
+
${r.cust}
|
|
149
|
+
</a>
|
|
150
|
+
</td>
|
|
151
|
+
<td>${r.reward}</td>
|
|
152
|
+
<td class="text-danger">-${formatPoints(r.amt)}</td>
|
|
153
|
+
</tr>
|
|
154
|
+
`;
|
|
155
|
+
tbody.append(rowHtml);
|
|
156
|
+
});
|
|
115
157
|
});
|
|
116
158
|
}
|
|
117
159
|
|
|
118
|
-
function
|
|
119
|
-
$('
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
function setupKasaScanner() {
|
|
161
|
+
$('#form-scan-qr').off('submit').on('submit', function (e) {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
const token = $('#qr-input').val().trim();
|
|
164
|
+
if (!token) return;
|
|
165
|
+
|
|
166
|
+
$.post('/api/niki-loyalty/scan-qr', { token: token }, function (res) {
|
|
167
|
+
if (res.success) {
|
|
168
|
+
app.alert({
|
|
169
|
+
type: 'success',
|
|
170
|
+
title: 'İşlem Başarılı! ✅',
|
|
171
|
+
message: `
|
|
172
|
+
<strong>Müşteri:</strong> ${res.customer.username}<br>
|
|
173
|
+
<strong>Verilen:</strong> ${res.rewardName}<br>
|
|
174
|
+
<strong>Tutar:</strong> ${res.cost} Puan
|
|
175
|
+
`,
|
|
176
|
+
timeout: 5000
|
|
177
|
+
});
|
|
178
|
+
$('#qr-input').val(''); // Inputu temizle
|
|
179
|
+
loadKasaHistory(); // Tabloyu güncelle
|
|
180
|
+
} else {
|
|
181
|
+
app.alert({ type: 'danger', title: 'Hata', message: res.message });
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
127
185
|
}
|
|
128
186
|
});
|