nodebb-plugin-niki-loyalty 1.0.20 → 1.0.22
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 +265 -89
- package/package.json +1 -1
- package/static/lib/client.js +272 -108
- package/templates/niki-wallet.tpl +232 -29
package/library.js
CHANGED
|
@@ -11,9 +11,25 @@ const Plugin = {};
|
|
|
11
11
|
// AYARLAR
|
|
12
12
|
// =========================
|
|
13
13
|
const SETTINGS = {
|
|
14
|
-
pointsPerHeartbeat:
|
|
15
|
-
dailyCap:
|
|
14
|
+
pointsPerHeartbeat: 5,
|
|
15
|
+
dailyCap: 250,
|
|
16
|
+
|
|
17
|
+
// Tek bar hedefi
|
|
18
|
+
barMax: 250,
|
|
19
|
+
|
|
20
|
+
// ✅ 4 kademe (Seçenek B: claim = puan düşer)
|
|
21
|
+
rewards: [
|
|
22
|
+
{ id: 'cookie', at: 60, title: 'Ücretsiz Kurabiye', type: 'free_item', meta: { item: 'cookie' } },
|
|
23
|
+
{ id: 'c35', at: 120, title: '%35 İndirimli Kahve', type: 'discount', meta: { product: 'coffee', percent: 35 } },
|
|
24
|
+
{ id: 'c60', at: 180, title: '%60 İndirimli Kahve', type: 'discount', meta: { product: 'coffee', percent: 60 } },
|
|
25
|
+
{ id: 'coffee', at: 250, title: 'Ücretsiz Kahve', type: 'free_item', meta: { item: 'coffee' } },
|
|
26
|
+
],
|
|
27
|
+
|
|
28
|
+
// Eski akış uyumluluğu (istersen sonra kaldırırız)
|
|
16
29
|
coffeeCost: 250,
|
|
30
|
+
|
|
31
|
+
// Kupon token süreleri (sn)
|
|
32
|
+
couponTTLSeconds: 10 * 60, // 10 dk
|
|
17
33
|
};
|
|
18
34
|
|
|
19
35
|
// ✅ TEST: sınırsız kullanım (puan kontrolünü kapatmak için true)
|
|
@@ -24,15 +40,12 @@ const TEST_MODE_UNLIMITED = false;
|
|
|
24
40
|
// =========================
|
|
25
41
|
function safeParseMaybeJson(x) {
|
|
26
42
|
if (x == null) return null;
|
|
27
|
-
|
|
28
|
-
// bazı DB’lerde object dönebilir
|
|
29
43
|
if (typeof x === 'object') return x;
|
|
30
44
|
|
|
31
45
|
if (typeof x === 'string') {
|
|
32
46
|
try {
|
|
33
47
|
return JSON.parse(x);
|
|
34
48
|
} catch (e) {
|
|
35
|
-
// "[object Object]" gibi bozuk kayıtları atla
|
|
36
49
|
return null;
|
|
37
50
|
}
|
|
38
51
|
}
|
|
@@ -53,15 +66,36 @@ function makeProfileUrl(userslug) {
|
|
|
53
66
|
return `${rp}/user/${userslug}`;
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
function makeToken() {
|
|
70
|
+
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getTodayKey() {
|
|
74
|
+
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findRewardById(id) {
|
|
78
|
+
return SETTINGS.rewards.find(r => r.id === id) || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function isStaff(uid) {
|
|
82
|
+
const [isAdmin, isMod] = await Promise.all([
|
|
83
|
+
user.isAdministrator(uid),
|
|
84
|
+
user.isGlobalModerator(uid),
|
|
85
|
+
]);
|
|
86
|
+
return isAdmin || isMod;
|
|
87
|
+
}
|
|
88
|
+
|
|
56
89
|
// =========================
|
|
57
90
|
// LOG FONKSİYONLARI
|
|
58
91
|
// =========================
|
|
59
|
-
async function addUserLog(uid, type, amount, desc) {
|
|
92
|
+
async function addUserLog(uid, type, amount, desc, extra) {
|
|
60
93
|
const logEntry = {
|
|
61
94
|
ts: Date.now(),
|
|
62
95
|
type, // 'earn' | 'spend'
|
|
63
96
|
amt: amount,
|
|
64
97
|
txt: desc,
|
|
98
|
+
...(extra ? { extra } : {}),
|
|
65
99
|
};
|
|
66
100
|
|
|
67
101
|
const payload = safeStringify(logEntry);
|
|
@@ -71,13 +105,15 @@ async function addUserLog(uid, type, amount, desc) {
|
|
|
71
105
|
await db.listTrim(`niki:activity:${uid}`, -50, -1);
|
|
72
106
|
}
|
|
73
107
|
|
|
74
|
-
async function addKasaLog(staffUid, customerName, customerUid) {
|
|
108
|
+
async function addKasaLog(staffUid, customerName, customerUid, amount, rewardId, rewardTitle) {
|
|
75
109
|
const logEntry = {
|
|
76
110
|
ts: Date.now(),
|
|
77
111
|
staff: staffUid,
|
|
78
|
-
cust: customerName,
|
|
112
|
+
cust: customerName,
|
|
79
113
|
cuid: customerUid,
|
|
80
|
-
amt:
|
|
114
|
+
amt: amount,
|
|
115
|
+
rewardId: rewardId || '',
|
|
116
|
+
rewardTitle: rewardTitle || '',
|
|
81
117
|
};
|
|
82
118
|
|
|
83
119
|
const payload = safeStringify(logEntry);
|
|
@@ -94,34 +130,41 @@ Plugin.init = async function (params) {
|
|
|
94
130
|
const router = params.router;
|
|
95
131
|
const middleware = params.middleware;
|
|
96
132
|
|
|
133
|
+
// -------------------------
|
|
97
134
|
// 1) HEARTBEAT (puan kazanma)
|
|
135
|
+
// -------------------------
|
|
98
136
|
router.post('/api/niki-loyalty/heartbeat', middleware.ensureLoggedIn, async (req, res) => {
|
|
99
137
|
try {
|
|
100
138
|
const uid = req.uid;
|
|
101
|
-
const today =
|
|
139
|
+
const today = getTodayKey();
|
|
102
140
|
const dailyKey = `niki:daily:${uid}:${today}`;
|
|
103
141
|
|
|
104
142
|
const currentDailyScore = parseInt((await db.getObjectField(dailyKey, 'score')) || 0, 10);
|
|
105
143
|
|
|
106
|
-
if (currentDailyScore >= SETTINGS.dailyCap) {
|
|
144
|
+
if (!TEST_MODE_UNLIMITED && currentDailyScore >= SETTINGS.dailyCap) {
|
|
107
145
|
return res.json({ earned: false, reason: 'daily_cap' });
|
|
108
146
|
}
|
|
109
147
|
|
|
110
148
|
await user.incrementUserFieldBy(uid, 'niki_points', SETTINGS.pointsPerHeartbeat);
|
|
111
149
|
await db.incrObjectFieldBy(dailyKey, 'score', SETTINGS.pointsPerHeartbeat);
|
|
112
150
|
|
|
113
|
-
const newBalance = await user.getUserField(uid, 'niki_points');
|
|
151
|
+
const newBalance = parseInt((await user.getUserField(uid, 'niki_points')) || 0, 10);
|
|
152
|
+
|
|
153
|
+
await addUserLog(uid, 'earn', SETTINGS.pointsPerHeartbeat, 'Aktiflik Puanı ⚡');
|
|
154
|
+
|
|
114
155
|
return res.json({ earned: true, points: SETTINGS.pointsPerHeartbeat, total: newBalance });
|
|
115
156
|
} catch (err) {
|
|
116
157
|
return res.status(500).json({ earned: false, reason: 'server_error' });
|
|
117
158
|
}
|
|
118
159
|
});
|
|
119
160
|
|
|
120
|
-
//
|
|
161
|
+
// -------------------------
|
|
162
|
+
// 2) WALLET DATA (cüzdan + geçmiş + rewards)
|
|
163
|
+
// -------------------------
|
|
121
164
|
router.get('/api/niki-loyalty/wallet-data', middleware.ensureLoggedIn, async (req, res) => {
|
|
122
165
|
try {
|
|
123
166
|
const uid = req.uid;
|
|
124
|
-
const today =
|
|
167
|
+
const today = getTodayKey();
|
|
125
168
|
|
|
126
169
|
const [userData, dailyData, historyRaw] = await Promise.all([
|
|
127
170
|
user.getUserFields(uid, ['niki_points']),
|
|
@@ -135,17 +178,33 @@ Plugin.init = async function (params) {
|
|
|
135
178
|
let dailyPercent = (dailyScore / SETTINGS.dailyCap) * 100;
|
|
136
179
|
if (dailyPercent > 100) dailyPercent = 100;
|
|
137
180
|
|
|
138
|
-
|
|
181
|
+
const barPercent = Math.min(100, (currentPoints / SETTINGS.barMax) * 100);
|
|
182
|
+
|
|
139
183
|
const history = (historyRaw || [])
|
|
140
184
|
.map(safeParseMaybeJson)
|
|
141
185
|
.filter(Boolean)
|
|
142
186
|
.reverse();
|
|
143
187
|
|
|
188
|
+
const rewards = SETTINGS.rewards.map(r => ({
|
|
189
|
+
id: r.id,
|
|
190
|
+
at: r.at,
|
|
191
|
+
title: r.title,
|
|
192
|
+
type: r.type,
|
|
193
|
+
meta: r.meta,
|
|
194
|
+
unlocked: currentPoints >= r.at,
|
|
195
|
+
}));
|
|
196
|
+
|
|
144
197
|
return res.json({
|
|
145
198
|
points: currentPoints,
|
|
199
|
+
|
|
146
200
|
dailyScore,
|
|
147
201
|
dailyCap: SETTINGS.dailyCap,
|
|
148
202
|
dailyPercent,
|
|
203
|
+
|
|
204
|
+
barMax: SETTINGS.barMax,
|
|
205
|
+
barPercent,
|
|
206
|
+
|
|
207
|
+
rewards,
|
|
149
208
|
history,
|
|
150
209
|
});
|
|
151
210
|
} catch (err) {
|
|
@@ -154,72 +213,187 @@ Plugin.init = async function (params) {
|
|
|
154
213
|
dailyScore: 0,
|
|
155
214
|
dailyCap: SETTINGS.dailyCap,
|
|
156
215
|
dailyPercent: 0,
|
|
216
|
+
barMax: SETTINGS.barMax,
|
|
217
|
+
barPercent: 0,
|
|
218
|
+
rewards: SETTINGS.rewards.map(r => ({ id: r.id, at: r.at, title: r.title, type: r.type, meta: r.meta, unlocked: false })),
|
|
157
219
|
history: [],
|
|
158
220
|
});
|
|
159
221
|
}
|
|
160
222
|
});
|
|
161
223
|
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Kayıtlar bazen JSON string, bazen bozuk olabilir → güvenli parse
|
|
172
|
-
const rows = (raw || [])
|
|
173
|
-
.map((x) => {
|
|
174
|
-
if (!x) return null;
|
|
175
|
-
if (typeof x === 'object') return x;
|
|
176
|
-
if (typeof x === 'string') {
|
|
177
|
-
try { return JSON.parse(x); } catch (e) { return null; }
|
|
178
|
-
}
|
|
179
|
-
return null;
|
|
180
|
-
})
|
|
181
|
-
.filter(Boolean)
|
|
182
|
-
.reverse();
|
|
183
|
-
|
|
184
|
-
// cuid’lerden uid listesi çıkar
|
|
185
|
-
const uids = rows
|
|
186
|
-
.map(r => parseInt(r.cuid, 10))
|
|
187
|
-
.filter(n => Number.isFinite(n) && n > 0);
|
|
188
|
-
|
|
189
|
-
// NodeBB core user datası (profile-looks mantığı)
|
|
190
|
-
const users = await user.getUsersFields(uids, [
|
|
191
|
-
'uid', 'username', 'userslug', 'picture', 'icon:bgColor',
|
|
192
|
-
]);
|
|
193
|
-
|
|
194
|
-
const userMap = {};
|
|
195
|
-
(users || []).forEach(u => { userMap[u.uid] = u; });
|
|
196
|
-
|
|
197
|
-
const rp = nconf.get('relative_path') || '';
|
|
198
|
-
|
|
199
|
-
const enriched = rows.map(r => {
|
|
200
|
-
const uid = parseInt(r.cuid, 10);
|
|
201
|
-
const u = userMap[uid];
|
|
202
|
-
if (!u) return r;
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
...r,
|
|
206
|
-
cust: u.username || r.cust || 'Bilinmeyen',
|
|
207
|
-
userslug: u.userslug || r.userslug || '',
|
|
208
|
-
picture: u.picture || r.picture || '',
|
|
209
|
-
iconBg: u['icon:bgColor'] || r.iconBg || '#4b5563',
|
|
210
|
-
profileUrl: (u.userslug ? `${rp}/user/${u.userslug}` : ''),
|
|
211
|
-
};
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
return res.json(enriched);
|
|
215
|
-
} catch (err) {
|
|
216
|
-
return res.status(500).json([]);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
224
|
+
// -------------------------
|
|
225
|
+
// 3) CLAIM REWARD (Seçenek B: claim = puan düşer + kupon token üretir)
|
|
226
|
+
// Client bu endpoint'i çağıracak.
|
|
227
|
+
// -------------------------
|
|
228
|
+
router.post('/api/niki-loyalty/claim', middleware.ensureLoggedIn, async (req, res) => {
|
|
229
|
+
try {
|
|
230
|
+
const uid = req.uid;
|
|
231
|
+
const rewardId = (req.body && req.body.rewardId) ? String(req.body.rewardId) : '';
|
|
232
|
+
const reward = findRewardById(rewardId);
|
|
219
233
|
|
|
234
|
+
if (!reward) {
|
|
235
|
+
return res.json({ success: false, message: 'Geçersiz ödül' });
|
|
236
|
+
}
|
|
220
237
|
|
|
238
|
+
const points = parseInt((await user.getUserField(uid, 'niki_points')) || 0, 10);
|
|
221
239
|
|
|
222
|
-
|
|
240
|
+
if (!TEST_MODE_UNLIMITED && points < reward.at) {
|
|
241
|
+
return res.json({ success: false, message: 'Yetersiz puan' });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ✅ Puan düş
|
|
245
|
+
if (!TEST_MODE_UNLIMITED) {
|
|
246
|
+
await user.decrementUserFieldBy(uid, 'niki_points', reward.at);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const newBalance = parseInt((await user.getUserField(uid, 'niki_points')) || 0, 10);
|
|
250
|
+
|
|
251
|
+
// ✅ Kupon oluştur (kasa tarayacak)
|
|
252
|
+
const token = makeToken();
|
|
253
|
+
const couponKey = `niki:coupon:${token}`;
|
|
254
|
+
|
|
255
|
+
const couponPayload = safeStringify({
|
|
256
|
+
token,
|
|
257
|
+
ts: Date.now(),
|
|
258
|
+
rewardId: reward.id,
|
|
259
|
+
at: reward.at,
|
|
260
|
+
title: reward.title,
|
|
261
|
+
type: reward.type,
|
|
262
|
+
meta: reward.meta,
|
|
263
|
+
ownerUid: uid,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
await db.set(couponKey, couponPayload);
|
|
267
|
+
await db.expire(couponKey, SETTINGS.couponTTLSeconds);
|
|
268
|
+
|
|
269
|
+
// user log
|
|
270
|
+
await addUserLog(uid, 'spend', reward.at, `Ödül alındı: ${reward.title}`, { rewardId: reward.id });
|
|
271
|
+
|
|
272
|
+
return res.json({
|
|
273
|
+
success: true,
|
|
274
|
+
token,
|
|
275
|
+
reward: { id: reward.id, at: reward.at, title: reward.title, type: reward.type, meta: reward.meta },
|
|
276
|
+
newBalance,
|
|
277
|
+
message: 'Ödül hazır! Kasada okutarak kullan.',
|
|
278
|
+
});
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return res.status(500).json({ success: false, message: 'Sunucu hatası' });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// -------------------------
|
|
285
|
+
// 4) KASA HISTORY (admin/mod)
|
|
286
|
+
// -------------------------
|
|
287
|
+
router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (req, res) => {
|
|
288
|
+
try {
|
|
289
|
+
const staffOk = await isStaff(req.uid);
|
|
290
|
+
if (!staffOk) return res.status(403).json([]);
|
|
291
|
+
|
|
292
|
+
const raw = await db.getListRange('niki:kasa:history', 0, -1);
|
|
293
|
+
|
|
294
|
+
const rows = (raw || [])
|
|
295
|
+
.map((x) => {
|
|
296
|
+
if (!x) return null;
|
|
297
|
+
if (typeof x === 'object') return x;
|
|
298
|
+
if (typeof x === 'string') {
|
|
299
|
+
try { return JSON.parse(x); } catch (e) { return null; }
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
})
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.reverse();
|
|
305
|
+
|
|
306
|
+
const uids = rows
|
|
307
|
+
.map(r => parseInt(r.cuid, 10))
|
|
308
|
+
.filter(n => Number.isFinite(n) && n > 0);
|
|
309
|
+
|
|
310
|
+
const users = await user.getUsersFields(uids, [
|
|
311
|
+
'uid', 'username', 'userslug', 'picture', 'icon:bgColor',
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
const userMap = {};
|
|
315
|
+
(users || []).forEach(u => { userMap[u.uid] = u; });
|
|
316
|
+
|
|
317
|
+
const rp = nconf.get('relative_path') || '';
|
|
318
|
+
|
|
319
|
+
const enriched = rows.map(r => {
|
|
320
|
+
const uid = parseInt(r.cuid, 10);
|
|
321
|
+
const u = userMap[uid];
|
|
322
|
+
if (!u) return r;
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
...r,
|
|
326
|
+
cust: u.username || r.cust || 'Bilinmeyen',
|
|
327
|
+
userslug: u.userslug || r.userslug || '',
|
|
328
|
+
picture: u.picture || r.picture || '',
|
|
329
|
+
iconBg: u['icon:bgColor'] || r.iconBg || '#4b5563',
|
|
330
|
+
profileUrl: (u.userslug ? `${rp}/user/${u.userslug}` : ''),
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return res.json(enriched);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return res.status(500).json([]);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// -------------------------
|
|
341
|
+
// 5) KASA: COUPON SCAN (admin/mod) ✅ yeni akış
|
|
342
|
+
// Claim ile üretilen token kasada okutulur.
|
|
343
|
+
// -------------------------
|
|
344
|
+
router.post('/api/niki-loyalty/scan-coupon', middleware.ensureLoggedIn, async (req, res) => {
|
|
345
|
+
try {
|
|
346
|
+
const token = (req.body && req.body.token) ? String(req.body.token) : '';
|
|
347
|
+
|
|
348
|
+
const staffOk = await isStaff(req.uid);
|
|
349
|
+
if (!staffOk) return res.status(403).json({ success: false, message: 'Yetkisiz' });
|
|
350
|
+
|
|
351
|
+
const raw = await db.get(`niki:coupon:${token}`);
|
|
352
|
+
if (!raw) return res.json({ success: false, message: 'Geçersiz / Süresi dolmuş kupon' });
|
|
353
|
+
|
|
354
|
+
const coupon = safeParseMaybeJson(raw);
|
|
355
|
+
if (!coupon || !coupon.ownerUid) {
|
|
356
|
+
await db.delete(`niki:coupon:${token}`);
|
|
357
|
+
return res.json({ success: false, message: 'Kupon bozuk' });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ✅ tek kullanımlık
|
|
361
|
+
await db.delete(`niki:coupon:${token}`);
|
|
362
|
+
|
|
363
|
+
const customerUid = parseInt(coupon.ownerUid, 10);
|
|
364
|
+
const customerData = await user.getUserFields(customerUid, ['username', 'picture', 'userslug']);
|
|
365
|
+
|
|
366
|
+
// kasa log (artık amount = coupon.at)
|
|
367
|
+
await addKasaLog(
|
|
368
|
+
req.uid,
|
|
369
|
+
customerData?.username || 'Bilinmeyen',
|
|
370
|
+
customerUid,
|
|
371
|
+
parseInt(coupon.at || 0, 10),
|
|
372
|
+
coupon.rewardId,
|
|
373
|
+
coupon.title
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
return res.json({
|
|
377
|
+
success: true,
|
|
378
|
+
customer: customerData,
|
|
379
|
+
coupon: {
|
|
380
|
+
rewardId: coupon.rewardId,
|
|
381
|
+
title: coupon.title,
|
|
382
|
+
type: coupon.type,
|
|
383
|
+
meta: coupon.meta,
|
|
384
|
+
at: coupon.at,
|
|
385
|
+
},
|
|
386
|
+
message: 'Kupon onaylandı!',
|
|
387
|
+
});
|
|
388
|
+
} catch (err) {
|
|
389
|
+
return res.status(500).json({ success: false, message: 'Sunucu hatası' });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// -------------------------
|
|
394
|
+
// 6) (ESKİ) QR OLUŞTUR / OKUT — opsiyonel uyumluluk
|
|
395
|
+
// İstersen tamamen kaldırırız. Şimdilik 250 "Ücretsiz Kahve" gibi düşünebilirsin.
|
|
396
|
+
// -------------------------
|
|
223
397
|
router.post('/api/niki-loyalty/generate-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
224
398
|
try {
|
|
225
399
|
const uid = req.uid;
|
|
@@ -229,7 +403,7 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
|
|
|
229
403
|
return res.json({ success: false, message: 'Yetersiz Puan' });
|
|
230
404
|
}
|
|
231
405
|
|
|
232
|
-
const token =
|
|
406
|
+
const token = makeToken();
|
|
233
407
|
|
|
234
408
|
await db.set(`niki:qr:${token}`, uid);
|
|
235
409
|
await db.expire(`niki:qr:${token}`, 120);
|
|
@@ -240,14 +414,12 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
|
|
|
240
414
|
}
|
|
241
415
|
});
|
|
242
416
|
|
|
243
|
-
// 5) QR OKUT (admin/mod)
|
|
244
417
|
router.post('/api/niki-loyalty/scan-qr', middleware.ensureLoggedIn, async (req, res) => {
|
|
245
418
|
try {
|
|
246
419
|
const token = (req.body && req.body.token) ? String(req.body.token) : '';
|
|
247
420
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
if (!isAdmin && !isMod) return res.status(403).json({ success: false, message: 'Yetkisiz' });
|
|
421
|
+
const staffOk = await isStaff(req.uid);
|
|
422
|
+
if (!staffOk) return res.status(403).json({ success: false, message: 'Yetkisiz' });
|
|
251
423
|
|
|
252
424
|
const customerUid = await db.get(`niki:qr:${token}`);
|
|
253
425
|
if (!customerUid) return res.json({ success: false, message: 'Geçersiz Kod' });
|
|
@@ -257,22 +429,16 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
|
|
|
257
429
|
return res.json({ success: false, message: 'Yetersiz Bakiye' });
|
|
258
430
|
}
|
|
259
431
|
|
|
260
|
-
// ✅ puan düşür
|
|
261
432
|
if (!TEST_MODE_UNLIMITED) {
|
|
262
433
|
await user.decrementUserFieldBy(customerUid, 'niki_points', SETTINGS.coffeeCost);
|
|
263
434
|
}
|
|
264
435
|
|
|
265
|
-
// token tek kullanımlık
|
|
266
436
|
await db.delete(`niki:qr:${token}`);
|
|
267
437
|
|
|
268
|
-
// müşteri bilgisi
|
|
269
438
|
const customerData = await user.getUserFields(customerUid, ['username', 'picture', 'userslug']);
|
|
270
439
|
|
|
271
|
-
// user log
|
|
272
440
|
await addUserLog(customerUid, 'spend', SETTINGS.coffeeCost, 'Kahve Keyfi ☕');
|
|
273
|
-
|
|
274
|
-
// kasa log
|
|
275
|
-
await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid);
|
|
441
|
+
await addKasaLog(req.uid, customerData?.username || 'Bilinmeyen', customerUid, SETTINGS.coffeeCost, 'coffee', 'Ücretsiz Kahve (QR)');
|
|
276
442
|
|
|
277
443
|
return res.json({
|
|
278
444
|
success: true,
|
|
@@ -284,15 +450,25 @@ router.get('/api/niki-loyalty/kasa-history', middleware.ensureLoggedIn, async (r
|
|
|
284
450
|
}
|
|
285
451
|
});
|
|
286
452
|
|
|
287
|
-
//
|
|
453
|
+
// -------------------------
|
|
454
|
+
// 7) SAYFA ROTASI (kasa sayfası)
|
|
455
|
+
// -------------------------
|
|
288
456
|
routeHelpers.setupPageRoute(router, '/niki-kasa', middleware, [], async (req, res) => {
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
if (!isAdmin && !isMod) return res.render('403', {});
|
|
457
|
+
const staffOk = await isStaff(req.uid);
|
|
458
|
+
if (!staffOk) return res.render('403', {});
|
|
292
459
|
return res.render('niki-kasa', { title: 'Niki Kasa' });
|
|
293
460
|
});
|
|
294
461
|
};
|
|
295
462
|
|
|
463
|
+
// -------------------------
|
|
464
|
+
// ✅ plugin.json'da action:topic.get -> checkTopicVisit vardı.
|
|
465
|
+
// Method yoksa NodeBB şikayet eder. Şimdilik NO-OP koydum.
|
|
466
|
+
// Sonra istersen gerçekten "topic view" ile puan ekleriz.
|
|
467
|
+
// -------------------------
|
|
468
|
+
Plugin.checkTopicVisit = async function () {
|
|
469
|
+
return;
|
|
470
|
+
};
|
|
471
|
+
|
|
296
472
|
// client.js inject
|
|
297
473
|
Plugin.addScripts = async function (scripts) {
|
|
298
474
|
scripts.push('plugins/nodebb-plugin-niki-loyalty/static/lib/client.js');
|
package/package.json
CHANGED
package/static/lib/client.js
CHANGED
|
@@ -1,128 +1,292 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
$(document).ready(function () {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
<span class="niki-val" id="niki-live-points">...</span>
|
|
16
|
-
</div>
|
|
17
|
-
</div>
|
|
4
|
+
// --- AYARLAR ---
|
|
5
|
+
const NIKI_LOGO_URL = "https://i.ibb.co/nZvtpss/logo-placeholder.png";
|
|
6
|
+
|
|
7
|
+
// Widget HTML Şablonu
|
|
8
|
+
const widgetHtml = `
|
|
9
|
+
<div id="niki-floating-widget" class="niki-hidden">
|
|
10
|
+
<div class="niki-widget-content" onclick="ajaxify.go('niki-wallet')">
|
|
11
|
+
<img src="${NIKI_LOGO_URL}" class="niki-widget-logo" alt="Niki">
|
|
12
|
+
<div class="niki-widget-text">
|
|
13
|
+
<span class="niki-lbl">PUANIM</span>
|
|
14
|
+
<span class="niki-val" id="niki-live-points">...</span>
|
|
18
15
|
</div>
|
|
19
|
-
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
`;
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// -------------------------
|
|
21
|
+
// UI Helpers
|
|
22
|
+
// -------------------------
|
|
23
|
+
function fixLogo() {
|
|
24
|
+
const img = document.querySelector("img.niki-widget-logo");
|
|
25
|
+
if (img && img.src !== NIKI_LOGO_URL) img.src = NIKI_LOGO_URL;
|
|
26
|
+
}
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
function showNikiToast(msg, kind) {
|
|
29
|
+
// kind: 'ok' | 'warn' | 'err' (şimdilik css yoksa da sorun değil)
|
|
30
|
+
$('.niki-toast').remove();
|
|
31
|
+
const icon = (kind === 'err') ? 'fa-triangle-exclamation' : (kind === 'warn' ? 'fa-circle-info' : 'fa-paw');
|
|
32
|
+
const toast = $(`<div class="niki-toast niki-${kind || 'ok'}"><i class="fa ${icon}"></i> ${msg}</div>`);
|
|
33
|
+
$('body').append(toast);
|
|
34
|
+
setTimeout(() => toast.addClass('show'), 50);
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
toast.removeClass('show');
|
|
37
|
+
setTimeout(() => toast.remove(), 350);
|
|
38
|
+
}, 3000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setPointsUI(points) {
|
|
42
|
+
const p = Number.isFinite(+points) ? +points : 0;
|
|
43
|
+
$('#niki-live-points').text(p);
|
|
44
|
+
$('#niki-floating-widget').removeClass('niki-hidden');
|
|
45
|
+
localStorage.setItem('niki_last_points', String(p));
|
|
46
|
+
|
|
47
|
+
// Wallet sayfasındaysan, oradaki puan UI'ını da güncellemeyi dene (id/class varsa)
|
|
48
|
+
// (Şablon değişken olabilir, yoksa dokunmaz)
|
|
49
|
+
$('#niki-wallet-points, .niki-wallet-points').text(p);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// -------------------------
|
|
53
|
+
// Data Fetch (wallet-data)
|
|
54
|
+
// -------------------------
|
|
55
|
+
function fetchWalletData(opts) {
|
|
56
|
+
opts = opts || {};
|
|
57
|
+
return $.get('/api/niki-loyalty/wallet-data')
|
|
58
|
+
.done(function (data) {
|
|
59
|
+
if (!data) return;
|
|
60
|
+
setPointsUI(data.points || 0);
|
|
61
|
+
|
|
62
|
+
// İstersen wallet sayfasında bar/milestone UI render etmek için data’yı sakla
|
|
63
|
+
window.__NIKI_WALLET_DATA__ = data;
|
|
29
64
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
$('#niki-live-points').text(cachedPoints);
|
|
35
|
-
$('#niki-floating-widget').removeClass('niki-hidden');
|
|
65
|
+
// Wallet template'in içine "rewards" render eden bir alan koyduysan burada çağır
|
|
66
|
+
// (Alan yoksa hiçbir şey olmaz, güvenli)
|
|
67
|
+
if (opts.renderRewards) {
|
|
68
|
+
renderWalletRewards(data);
|
|
36
69
|
}
|
|
70
|
+
})
|
|
71
|
+
.fail(function () {
|
|
72
|
+
// cache yoksa 0 göster
|
|
73
|
+
const cached = localStorage.getItem('niki_last_points');
|
|
74
|
+
if (cached === null) {
|
|
75
|
+
setPointsUI(0);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -------------------------
|
|
81
|
+
// Wallet Rewards Render + Claim (Seçenek B)
|
|
82
|
+
// Bu render ancak sayfanda #niki-rewards gibi bir container varsa çalışır.
|
|
83
|
+
// Yoksa dokunmaz.
|
|
84
|
+
// -------------------------
|
|
85
|
+
function renderWalletRewards(data) {
|
|
86
|
+
const $wrap = $('#niki-rewards');
|
|
87
|
+
if ($wrap.length === 0) return; // container yoksa geç
|
|
88
|
+
|
|
89
|
+
const points = Number(data.points || 0);
|
|
90
|
+
const barMax = Number(data.barMax || 250);
|
|
91
|
+
const rewards = Array.isArray(data.rewards) ? data.rewards : [];
|
|
37
92
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
93
|
+
// Basit milestone bar (container varsa)
|
|
94
|
+
const barPercent = Math.min(100, (points / barMax) * 100);
|
|
95
|
+
|
|
96
|
+
let barHtml = `
|
|
97
|
+
<div class="niki-mbar">
|
|
98
|
+
<div class="niki-mbar-top">
|
|
99
|
+
<div class="niki-mbar-title">Ödül Barı</div>
|
|
100
|
+
<div class="niki-mbar-val">${points} / ${barMax}</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="niki-mbar-track">
|
|
103
|
+
<div class="niki-mbar-fill" style="width:${barPercent}%"></div>
|
|
104
|
+
${rewards.map(r => {
|
|
105
|
+
const left = Math.min(100, (Number(r.at) / barMax) * 100);
|
|
106
|
+
const unlocked = points >= Number(r.at);
|
|
107
|
+
return `<span class="niki-mbar-dot ${unlocked ? 'on' : ''}" style="left:${left}%"
|
|
108
|
+
title="${escapeHtml(r.title)} • ${r.at} puan"></span>`;
|
|
109
|
+
}).join('')}
|
|
110
|
+
</div>
|
|
111
|
+
<div class="niki-mbar-hint">Ödülü aldığında puanın düşer.</div>
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
let cardsHtml = rewards.map(r => {
|
|
116
|
+
const at = Number(r.at || 0);
|
|
117
|
+
const unlocked = points >= at;
|
|
118
|
+
|
|
119
|
+
const badge = unlocked ? `<span class="niki-r-badge on">Alınabilir</span>` : `<span class="niki-r-badge">Kilitli</span>`;
|
|
120
|
+
const btn = unlocked
|
|
121
|
+
? `<button class="btn btn-sm btn-primary niki-claim-btn" data-reward="${escapeAttr(r.id)}">
|
|
122
|
+
Ödülü Al
|
|
123
|
+
</button>`
|
|
124
|
+
: `<button class="btn btn-sm btn-default niki-claim-btn" disabled>
|
|
125
|
+
${at} puan gerekli
|
|
126
|
+
</button>`;
|
|
127
|
+
|
|
128
|
+
return `
|
|
129
|
+
<div class="niki-r-card ${unlocked ? 'on' : ''}">
|
|
130
|
+
<div class="niki-r-left">
|
|
131
|
+
<div class="niki-r-title">${escapeHtml(r.title)}</div>
|
|
132
|
+
<div class="niki-r-sub">${at} puan</div>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="niki-r-right">
|
|
135
|
+
${badge}
|
|
136
|
+
${btn}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
`;
|
|
140
|
+
}).join('');
|
|
141
|
+
|
|
142
|
+
$wrap.html(barHtml + `<div class="niki-r-list">${cardsHtml}</div>`);
|
|
143
|
+
|
|
144
|
+
// Claim handler
|
|
145
|
+
$wrap.off('click.nikiClaim').on('click.nikiClaim', '.niki-claim-btn:not([disabled])', function () {
|
|
146
|
+
const rewardId = $(this).data('reward');
|
|
147
|
+
if (!rewardId) return;
|
|
148
|
+
|
|
149
|
+
// Butonu anlık kilitle (double click engeli)
|
|
150
|
+
const $btn = $(this);
|
|
151
|
+
$btn.prop('disabled', true).text('Hazırlanıyor...');
|
|
152
|
+
|
|
153
|
+
claimReward(rewardId)
|
|
154
|
+
.always(() => {
|
|
155
|
+
// UI refresh, button state render sonrası zaten güncellenecek
|
|
61
156
|
});
|
|
62
|
-
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
63
159
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
160
|
+
function claimReward(rewardId) {
|
|
161
|
+
return $.ajax({
|
|
162
|
+
url: '/api/niki-loyalty/claim',
|
|
163
|
+
method: 'POST',
|
|
164
|
+
data: {
|
|
165
|
+
rewardId: rewardId,
|
|
166
|
+
_csrf: config.csrf_token,
|
|
167
|
+
},
|
|
168
|
+
}).done(function (res) {
|
|
169
|
+
if (!res || !res.success) {
|
|
170
|
+
showNikiToast(res?.message || 'Ödül alınamadı', 'err');
|
|
171
|
+
// Wallet UI’ı tazele
|
|
172
|
+
fetchWalletData({ renderRewards: true });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
71
175
|
|
|
72
|
-
|
|
73
|
-
|
|
176
|
+
// ✅ Puan düşmüş yeni bakiye
|
|
177
|
+
if (typeof res.newBalance !== 'undefined') {
|
|
178
|
+
setPointsUI(res.newBalance);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ✅ Token geldi (kasada okutulacak)
|
|
182
|
+
// Token'ı localStorage'a kaydedelim (wallet ekranı QR üretirken kullanabilsin)
|
|
183
|
+
if (res.token) {
|
|
184
|
+
localStorage.setItem('niki_last_coupon_token', res.token);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
showNikiToast(res.message || 'Ödül hazır! Kasada okut.', 'ok');
|
|
74
188
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
189
|
+
// Wallet sayfasını tazele ve rewards’ları yeniden render et
|
|
190
|
+
fetchWalletData({ renderRewards: true });
|
|
191
|
+
|
|
192
|
+
// Eğer wallet template’in içinde "QR göster" alanın varsa, event ile haber ver
|
|
193
|
+
$(window).trigger('niki:coupon.ready', [res]);
|
|
194
|
+
}).fail(function () {
|
|
195
|
+
showNikiToast('Sunucu hatası: ödül alınamadı', 'err');
|
|
196
|
+
fetchWalletData({ renderRewards: true });
|
|
79
197
|
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Basit escape helper’lar (XSS koruması)
|
|
201
|
+
function escapeHtml(str) {
|
|
202
|
+
return String(str || '')
|
|
203
|
+
.replaceAll('&', '&')
|
|
204
|
+
.replaceAll('<', '<')
|
|
205
|
+
.replaceAll('>', '>')
|
|
206
|
+
.replaceAll('"', '"')
|
|
207
|
+
.replaceAll("'", ''');
|
|
208
|
+
}
|
|
209
|
+
function escapeAttr(str) {
|
|
210
|
+
return escapeHtml(str).replaceAll(' ', '');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -------------------------
|
|
214
|
+
// 1) Widget Başlatma ve Veri Yönetimi
|
|
215
|
+
// -------------------------
|
|
216
|
+
function initNikiWidget() {
|
|
217
|
+
if (!app.user.uid || app.user.uid <= 0) return;
|
|
80
218
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let idleTimer;
|
|
85
|
-
|
|
86
|
-
function resetIdleTimer() {
|
|
87
|
-
isUserActive = true;
|
|
88
|
-
clearTimeout(idleTimer);
|
|
89
|
-
idleTimer = setTimeout(() => { isUserActive = false; }, 30000);
|
|
219
|
+
// Widget yoksa ekle
|
|
220
|
+
if ($('#niki-floating-widget').length === 0) {
|
|
221
|
+
$('body').append(widgetHtml);
|
|
90
222
|
}
|
|
91
|
-
$(window).on('mousemove scroll keydown click touchstart', resetIdleTimer);
|
|
92
223
|
|
|
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);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
224
|
+
// Cache: anında göster
|
|
225
|
+
const cachedPoints = localStorage.getItem('niki_last_points');
|
|
226
|
+
if (cachedPoints !== null) {
|
|
227
|
+
$('#niki-live-points').text(cachedPoints);
|
|
228
|
+
$('#niki-floating-widget').removeClass('niki-hidden');
|
|
116
229
|
}
|
|
117
230
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
231
|
+
fixLogo();
|
|
232
|
+
|
|
233
|
+
// Sunucudan taze veri çek
|
|
234
|
+
const onWalletPage = (ajaxify.data && ajaxify.data.template === 'niki-wallet');
|
|
235
|
+
fetchWalletData({ renderRewards: onWalletPage }).always(function () {
|
|
236
|
+
fixLogo();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Başlat
|
|
241
|
+
initNikiWidget();
|
|
242
|
+
|
|
243
|
+
// Sayfa geçişlerinde tekrar çalıştır
|
|
244
|
+
$(window).on('action:ajaxify.end', function () {
|
|
245
|
+
initNikiWidget();
|
|
246
|
+
setTimeout(fixLogo, 400);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// -------------------------
|
|
250
|
+
// --- AKTİFLİK SİSTEMİ (Heartbeat) ---
|
|
251
|
+
// -------------------------
|
|
252
|
+
let activeSeconds = 0;
|
|
253
|
+
let isUserActive = false;
|
|
254
|
+
let idleTimer;
|
|
255
|
+
|
|
256
|
+
function resetIdleTimer() {
|
|
257
|
+
isUserActive = true;
|
|
258
|
+
clearTimeout(idleTimer);
|
|
259
|
+
idleTimer = setTimeout(() => { isUserActive = false; }, 30000);
|
|
260
|
+
}
|
|
261
|
+
$(window).on('mousemove scroll keydown click touchstart', resetIdleTimer);
|
|
262
|
+
|
|
263
|
+
setInterval(() => {
|
|
264
|
+
if (ajaxify.data && ajaxify.data.template && ajaxify.data.template.topic && document.visibilityState === 'visible' && isUserActive) {
|
|
265
|
+
activeSeconds++;
|
|
266
|
+
}
|
|
267
|
+
if (activeSeconds >= 60) {
|
|
268
|
+
sendHeartbeat();
|
|
269
|
+
activeSeconds = 0;
|
|
127
270
|
}
|
|
128
|
-
});
|
|
271
|
+
}, 1000);
|
|
272
|
+
|
|
273
|
+
function sendHeartbeat() {
|
|
274
|
+
$.post('/api/niki-loyalty/heartbeat', { _csrf: config.csrf_token }, function (res) {
|
|
275
|
+
if (res && res.earned) {
|
|
276
|
+
setPointsUI(res.total);
|
|
277
|
+
|
|
278
|
+
showNikiToast(`+${res.points} Puan Kazandın! ⚡`, 'ok');
|
|
279
|
+
|
|
280
|
+
$('#niki-floating-widget').addClass('niki-bounce');
|
|
281
|
+
setTimeout(() => $('#niki-floating-widget').removeClass('niki-bounce'), 500);
|
|
282
|
+
|
|
283
|
+
// Wallet sayfasındaysan rewards'ları da tazele
|
|
284
|
+
const onWalletPage = (ajaxify.data && ajaxify.data.template === 'niki-wallet');
|
|
285
|
+
if (onWalletPage) {
|
|
286
|
+
fetchWalletData({ renderRewards: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
@@ -1,35 +1,238 @@
|
|
|
1
1
|
<div class="niki-wallet-wrapper">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
<div class="niki-header-bg"></div>
|
|
3
|
+
|
|
4
|
+
<div class="niki-wallet-content">
|
|
5
|
+
<div class="niki-wallet-avatar">
|
|
6
|
+
<img src="https://i.imgur.com/kXUe4M6.png" alt="Niki">
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="niki-balance-label">Toplam Bakiye</div>
|
|
10
|
+
<!-- client.js anlık güncelleyebilsin diye id ekledim -->
|
|
11
|
+
<div class="niki-balance-big" id="niki-wallet-points">{points}</div>
|
|
12
|
+
|
|
13
|
+
<!-- ✅ ÖDÜL BARI + KADEME DOTLARI (60/120/180/250) -->
|
|
14
|
+
<div class="niki-reward-stats">
|
|
15
|
+
<div style="display:flex; justify-content:space-between; font-size:12px; color:#bdbdbd; font-weight:700;">
|
|
16
|
+
<span>Ödül Barı</span>
|
|
17
|
+
<span><span id="niki-bar-points">{points}</span> / <span id="niki-bar-max">250</span></span>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="niki-progress-track niki-reward-track" style="position:relative;">
|
|
21
|
+
<div class="niki-progress-fill" id="niki-reward-fill" style="width: 0%;"></div>
|
|
22
|
+
|
|
23
|
+
<!-- Milestone dotlar -->
|
|
24
|
+
<span class="niki-ms-dot" data-at="60" style="left:24%;" title="60 • Ücretsiz Kurabiye"></span>
|
|
25
|
+
<span class="niki-ms-dot" data-at="120" style="left:48%;" title="120 • %35 İndirimli Kahve"></span>
|
|
26
|
+
<span class="niki-ms-dot" data-at="180" style="left:72%;" title="180 • %60 İndirimli Kahve"></span>
|
|
27
|
+
<span class="niki-ms-dot" data-at="250" style="left:100%;" title="250 • Ücretsiz Kahve"></span>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div style="font-size:11px; color:#a7a7a7; margin-top:6px;">
|
|
31
|
+
Ödülü aldığında <b style="color:#fff;">puan düşer</b>. (Kasa için QR/kupon oluşur.)
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- ✅ REWARD KARTLARI (client.js #niki-rewards içine basacak) -->
|
|
36
|
+
<div id="niki-rewards" style="margin-top:16px;"></div>
|
|
37
|
+
|
|
38
|
+
<!-- ✅ Kupon/QR alanı (claim sonrası token burada gösterilecek) -->
|
|
39
|
+
<div id="niki-coupon-area" style="display:none; margin-top:16px;">
|
|
40
|
+
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
|
|
41
|
+
<div style="font-weight:800; color:#fff;">Kasada Okut</div>
|
|
42
|
+
<button class="niki-mini-btn" id="niki-coupon-close" type="button">Kapat</button>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="niki-coupon-card">
|
|
46
|
+
<div style="font-size:12px; color:#cfcfcf; margin-bottom:8px;">
|
|
47
|
+
<span id="niki-coupon-title">Ödül Kuponu</span>
|
|
7
48
|
</div>
|
|
8
49
|
|
|
9
|
-
<div class="niki-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<div class="niki-daily-stats">
|
|
13
|
-
<div style="display:flex; justify-content:space-between; font-size:12px; color:#888; font-weight:600;">
|
|
14
|
-
<span>Günlük Kazanım</span>
|
|
15
|
-
<span>{dailyScore} / {dailyCap}</span>
|
|
16
|
-
</div>
|
|
17
|
-
|
|
18
|
-
<div class="niki-progress-track">
|
|
19
|
-
<div class="niki-progress-fill" style="width: {dailyPercent}%;"></div>
|
|
20
|
-
</div>
|
|
21
|
-
|
|
22
|
-
<div style="font-size:11px; color:#aaa;">
|
|
23
|
-
Bugün daha fazla çalışarak limitini doldurabilirsin!
|
|
24
|
-
</div>
|
|
50
|
+
<div id="niki-coupon-qr" class="niki-qr-box">
|
|
51
|
+
<!-- QR burada üretilecek (client.js event ile doldurabilir) -->
|
|
52
|
+
<div style="color:#aaa; font-size:12px;">QR hazırlanıyor...</div>
|
|
25
53
|
</div>
|
|
26
54
|
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
</
|
|
30
|
-
|
|
31
|
-
<p style="font-size:12px; color:#ccc; margin-top:15px;">
|
|
32
|
-
Niki The Cat Coffee © Loyalty Program
|
|
33
|
-
</p>
|
|
55
|
+
<div style="margin-top:10px; font-size:11px; color:#a7a7a7;">
|
|
56
|
+
Kuponun süresi sınırlı olabilir. Kasada okutunca tek kullanımlık olur.
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
34
59
|
</div>
|
|
35
|
-
|
|
60
|
+
|
|
61
|
+
<!-- ✅ Günlük kazanım aynı kaldı -->
|
|
62
|
+
<div class="niki-daily-stats" style="margin-top:18px;">
|
|
63
|
+
<div style="display:flex; justify-content:space-between; font-size:12px; color:#888; font-weight:700;">
|
|
64
|
+
<span>Günlük Kazanım</span>
|
|
65
|
+
<span>{dailyScore} / {dailyCap}</span>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="niki-progress-track">
|
|
69
|
+
<div class="niki-progress-fill" style="width: {dailyPercent}%;"></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div style="font-size:11px; color:#aaa;">
|
|
73
|
+
Bugün daha fazla çalışarak limitini doldurabilirsin!
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<!-- ❌ Eski kahve QR butonunu kaldırmadım ama UX için “Eski Sistem” diye ayırdım -->
|
|
78
|
+
<button class="niki-btn-action" id="niki-old-qr-btn" style="margin-top:14px;">
|
|
79
|
+
<i class="fa fa-qrcode"></i> (Eski) 250 Puan → QR Oluştur
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
<p style="font-size:12px; color:#ccc; margin-top:15px;">
|
|
83
|
+
Niki The Cat Coffee © Loyalty Program
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<style>
|
|
89
|
+
/* Bu küçük CSS'ler sadece yeni bölümleri düzgün gösterir */
|
|
90
|
+
.niki-reward-stats{ margin-top: 14px; }
|
|
91
|
+
|
|
92
|
+
.niki-reward-track{ height: 12px; border-radius: 999px; overflow: visible; }
|
|
93
|
+
#niki-reward-fill{ height: 100%; border-radius: 999px; }
|
|
94
|
+
|
|
95
|
+
.niki-ms-dot{
|
|
96
|
+
position:absolute; top:50%;
|
|
97
|
+
transform: translate(-50%, -50%);
|
|
98
|
+
width: 12px; height: 12px; border-radius: 999px;
|
|
99
|
+
background: rgba(255,255,255,.18);
|
|
100
|
+
border: 1px solid rgba(255,255,255,.28);
|
|
101
|
+
box-shadow: 0 6px 14px rgba(0,0,0,.25);
|
|
102
|
+
}
|
|
103
|
+
.niki-ms-dot.on{
|
|
104
|
+
background: rgba(255,255,255,.95);
|
|
105
|
+
border-color: rgba(255,255,255,.95);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* rewards container'ın içini client.js dolduruyor */
|
|
109
|
+
.niki-mbar{ margin-bottom: 12px; }
|
|
110
|
+
.niki-mbar-top{ display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
|
111
|
+
.niki-mbar-title{ font-size:12px; font-weight:800; color:#fff; }
|
|
112
|
+
.niki-mbar-val{ font-size:12px; font-weight:800; color:#d7d7d7; }
|
|
113
|
+
.niki-mbar-track{ position:relative; height:10px; border-radius:999px; background: rgba(255,255,255,.10); overflow: hidden; }
|
|
114
|
+
.niki-mbar-fill{ height:100%; border-radius:999px; background: rgba(255,255,255,.85); width:0%; }
|
|
115
|
+
.niki-mbar-dot{
|
|
116
|
+
position:absolute; top:50%; transform: translate(-50%,-50%);
|
|
117
|
+
width:10px; height:10px; border-radius:999px;
|
|
118
|
+
background: rgba(255,255,255,.18);
|
|
119
|
+
border: 1px solid rgba(255,255,255,.28);
|
|
120
|
+
}
|
|
121
|
+
.niki-mbar-dot.on{ background: rgba(255,255,255,.95); border-color: rgba(255,255,255,.95); }
|
|
122
|
+
.niki-mbar-hint{ margin-top:6px; font-size:11px; color:#a7a7a7; }
|
|
123
|
+
|
|
124
|
+
.niki-r-list{ display:flex; flex-direction:column; gap:10px; }
|
|
125
|
+
.niki-r-card{
|
|
126
|
+
display:flex; justify-content:space-between; align-items:center;
|
|
127
|
+
padding: 12px 12px;
|
|
128
|
+
border-radius: 14px;
|
|
129
|
+
background: rgba(255,255,255,.06);
|
|
130
|
+
border: 1px solid rgba(255,255,255,.10);
|
|
131
|
+
}
|
|
132
|
+
.niki-r-card.on{
|
|
133
|
+
background: rgba(255,255,255,.10);
|
|
134
|
+
border-color: rgba(255,255,255,.18);
|
|
135
|
+
}
|
|
136
|
+
.niki-r-title{ font-weight:800; color:#fff; font-size:13px; }
|
|
137
|
+
.niki-r-sub{ color:#bdbdbd; font-size:11px; margin-top:2px; }
|
|
138
|
+
.niki-r-right{ display:flex; gap:10px; align-items:center; }
|
|
139
|
+
.niki-r-badge{
|
|
140
|
+
font-size:11px; font-weight:800;
|
|
141
|
+
padding: 6px 10px;
|
|
142
|
+
border-radius: 999px;
|
|
143
|
+
background: rgba(255,255,255,.08);
|
|
144
|
+
color: #cfcfcf;
|
|
145
|
+
border: 1px solid rgba(255,255,255,.10);
|
|
146
|
+
white-space: nowrap;
|
|
147
|
+
}
|
|
148
|
+
.niki-r-badge.on{
|
|
149
|
+
background: rgba(255,255,255,.18);
|
|
150
|
+
color:#fff;
|
|
151
|
+
border-color: rgba(255,255,255,.22);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.niki-mini-btn{
|
|
155
|
+
background: rgba(255,255,255,.10);
|
|
156
|
+
border: 1px solid rgba(255,255,255,.18);
|
|
157
|
+
color:#fff;
|
|
158
|
+
padding: 6px 10px;
|
|
159
|
+
border-radius: 10px;
|
|
160
|
+
font-weight:800;
|
|
161
|
+
font-size:12px;
|
|
162
|
+
cursor:pointer;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.niki-coupon-card{
|
|
166
|
+
padding: 14px;
|
|
167
|
+
border-radius: 16px;
|
|
168
|
+
background: rgba(255,255,255,.06);
|
|
169
|
+
border: 1px solid rgba(255,255,255,.10);
|
|
170
|
+
}
|
|
171
|
+
.niki-qr-box{
|
|
172
|
+
width: 100%;
|
|
173
|
+
min-height: 220px;
|
|
174
|
+
border-radius: 14px;
|
|
175
|
+
display:flex; align-items:center; justify-content:center;
|
|
176
|
+
background: rgba(0,0,0,.20);
|
|
177
|
+
border: 1px dashed rgba(255,255,255,.18);
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
180
|
+
|
|
181
|
+
<script>
|
|
182
|
+
// Bu script sadece wallet sayfasında barı güncellemek için minik bir helper.
|
|
183
|
+
// Asıl render/claim zaten güncel client.js içinde.
|
|
184
|
+
(function(){
|
|
185
|
+
function applyRewardBar(data){
|
|
186
|
+
try{
|
|
187
|
+
const points = Number(data.points || 0);
|
|
188
|
+
const barMax = Number(data.barMax || 250);
|
|
189
|
+
const pct = Math.min(100, (points / barMax) * 100);
|
|
190
|
+
|
|
191
|
+
const fill = document.getElementById('niki-reward-fill');
|
|
192
|
+
if (fill) fill.style.width = pct + '%';
|
|
193
|
+
|
|
194
|
+
const bp = document.getElementById('niki-bar-points');
|
|
195
|
+
if (bp) bp.textContent = points;
|
|
196
|
+
|
|
197
|
+
const bm = document.getElementById('niki-bar-max');
|
|
198
|
+
if (bm) bm.textContent = barMax;
|
|
199
|
+
|
|
200
|
+
// dot highlight (60/120/180/250)
|
|
201
|
+
document.querySelectorAll('.niki-ms-dot').forEach(dot=>{
|
|
202
|
+
const at = Number(dot.getAttribute('data-at') || 0);
|
|
203
|
+
if (points >= at) dot.classList.add('on');
|
|
204
|
+
else dot.classList.remove('on');
|
|
205
|
+
});
|
|
206
|
+
}catch(e){}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// client.js zaten wallet-data'yı window.__NIKI_WALLET_DATA__ içine koyuyor
|
|
210
|
+
if (window.__NIKI_WALLET_DATA__) applyRewardBar(window.__NIKI_WALLET_DATA__);
|
|
211
|
+
|
|
212
|
+
// ajaxify sonrası tekrar dene
|
|
213
|
+
$(window).on('action:ajaxify.end', function(){
|
|
214
|
+
if (window.__NIKI_WALLET_DATA__) applyRewardBar(window.__NIKI_WALLET_DATA__);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// claim sonrası event
|
|
218
|
+
$(window).on('niki:coupon.ready', function(_, res){
|
|
219
|
+
// İstersen burada QR üretip #niki-coupon-qr içine basarsın.
|
|
220
|
+
// (QR üretim kütüphanen wallet sayfasında varsa entegre edebiliriz.)
|
|
221
|
+
const area = document.getElementById('niki-coupon-area');
|
|
222
|
+
if (area) area.style.display = 'block';
|
|
223
|
+
const t = document.getElementById('niki-coupon-title');
|
|
224
|
+
if (t && res && res.reward && res.reward.title) t.textContent = res.reward.title + ' • Kupon';
|
|
225
|
+
|
|
226
|
+
// Şimdilik tokenı text olarak da gösterelim (QR yoksa bile)
|
|
227
|
+
const qrBox = document.getElementById('niki-coupon-qr');
|
|
228
|
+
if (qrBox && res && res.token){
|
|
229
|
+
qrBox.innerHTML = '<div style="text-align:center;color:#fff;font-weight:800;">TOKEN</div>'
|
|
230
|
+
+ '<div style="margin-top:6px;color:#cfcfcf;font-size:12px;word-break:break-all;">' + String(res.token) + '</div>';
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
$('#niki-coupon-close').on('click', function(){
|
|
235
|
+
$('#niki-coupon-area').hide();
|
|
236
|
+
});
|
|
237
|
+
})();
|
|
238
|
+
</script>
|