1<?php
2/**
3 * Title: Shop Manager
4 * Icon: fa-shopping-cart
5 * Group: Economy
6 * Order: 10
7 * Description: Add, preview, hide and remove database shop offers.
8 */
9
10if (!defined('ACP_ROOT')) {
11 http_response_code(403);
12 die('Direct access denied.');
13}
14
15$items = getItemList();
16
17db()->execute("
18 CREATE TABLE IF NOT EXISTS `znote_shop_offers` (
19 `id` int NOT NULL AUTO_INCREMENT,
20 `type` int NOT NULL,
21 `itemid` int DEFAULT NULL,
22 `count` int NOT NULL DEFAULT '1',
23 `description` varchar(255) NOT NULL,
24 `points` int NOT NULL DEFAULT '10',
25 `active` tinyint NOT NULL DEFAULT '1',
26 `sort_order` int NOT NULL DEFAULT '0',
27 `created_by` int DEFAULT NULL,
28 `created_at` int DEFAULT NULL,
29 `updated_at` int DEFAULT NULL,
30 PRIMARY KEY (`id`),
31 KEY `active_sort` (`active`, `sort_order`, `id`)
32 ) ENGINE=InnoDB;
33");
34
35function acp_shop_offer_columns(): array {
36 $columns = [];
37 $rows = db()->fetchAll("SHOW COLUMNS FROM `znote_shop_offers`;");
38
39 if (is_array($rows)) {
40 foreach ($rows as $row) {
41 if (!empty($row['Field'])) {
42 $columns[(string)$row['Field']] = true;
43 }
44 }
45 }
46
47 return $columns;
48}
49
50function acp_shop_ensure_offer_schema(): void {
51 $columns = acp_shop_offer_columns();
52
53 if (empty($columns['active'])) {
54 db()->execute("ALTER TABLE `znote_shop_offers` ADD `active` tinyint NOT NULL DEFAULT '1' AFTER `points`;");
55 $columns['active'] = true;
56 }
57 if (empty($columns['sort_order'])) {
58 db()->execute("ALTER TABLE `znote_shop_offers` ADD `sort_order` int NOT NULL DEFAULT '0' AFTER `active`;");
59 $columns['sort_order'] = true;
60 }
61 if (empty($columns['updated_at'])) {
62 db()->execute("ALTER TABLE `znote_shop_offers` ADD `updated_at` int DEFAULT NULL AFTER `created_at`;");
63 $columns['updated_at'] = true;
64 }
65
66 $indexes = db()->fetchAll("SHOW INDEX FROM `znote_shop_offers` WHERE `Key_name` = 'active_sort';");
67 if (empty($indexes) && !empty($columns['active']) && !empty($columns['sort_order'])) {
68 db()->execute("ALTER TABLE `znote_shop_offers` ADD KEY `active_sort` (`active`, `sort_order`, `id`);");
69 }
70}
71
72acp_shop_ensure_offer_schema();
73
74function acp_shop_pack_outfit_pair(string $raw): int|false {
75 if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $raw, $m)) {
76 return false;
77 }
78
79 return ((int)$m[1] * 10000) + (int)$m[2];
80}
81
82function acp_shop_unpack_outfit_pair(int $packed): string {
83 $male = (int)floor($packed / 10000);
84 $female = (int)($packed % 10000);
85
86 return $male . ',' . $female;
87}
88
89function acp_shop_offer_type_label(int $type): string {
90 $types = [
91 1 => t('acp.shp.type_item'),
92 2 => t('acp.shp.type_premium'),
93 3 => t('acp.shp.type_gender'),
94 4 => t('acp.shp.type_name'),
95 5 => t('acp.shp.type_outfit'),
96 6 => t('acp.shp.type_mount'),
97 7 => t('acp.shp.type_custom'),
98 8 => t('acp.shp.type_custom'),
99 ];
100
101 return $types[$type] ?? t('acp.shp.type_custom');
102}
103
104function acp_shop_public_asset_url(string $url): string {
105 if ($url === '' || preg_match('~^(https?:)?//~i', $url) || $url[0] === '/') {
106 return $url;
107 }
108
109 return acp_site($url);
110}
111
112function acp_shop_item_image_url(int $itemId): string {
113 global $config;
114
115 if (function_exists('znote_item_image_url')) {
116 return acp_shop_public_asset_url(znote_item_image_url($itemId));
117 }
118
119 $server = trim((string)($config['shop']['imageServer'] ?? ''), '/');
120 $typeExt = trim((string)($config['shop']['imageType'] ?? 'gif'), '.');
121
122 if ($server === '' || $itemId <= 0) {
123 return '';
124 }
125
126 if (preg_match('~^https?://~i', $server)) {
127 return $server . '/' . $itemId . '.' . $typeExt;
128 }
129
130 if (str_contains($server, '.')) {
131 return 'http://' . $server . '/' . $itemId . '.' . $typeExt;
132 }
133
134 return acp_shop_public_asset_url($server . '/' . $itemId . '.' . $typeExt);
135}
136
137function acp_shop_outfit_server_url(): string {
138 global $config;
139
140 $server = trim((string)($config['show_outfits']['imageServer'] ?? ''));
141 if ($server === '') {
142 return '';
143 }
144
145 if (preg_match('~^(https?:)?//~i', $server) || substr($server, 0, 1) === '/') {
146 return $server;
147 }
148
149 return acp_site($server);
150}
151
152function acp_shop_outfit_image_url(int $outfitId, int $addons, int $mountId = 0): string {
153 $server = acp_shop_outfit_server_url();
154 if ($server === '' || $outfitId <= 0) {
155 return '';
156 }
157
158 $url = $server
159 . '?id=' . $outfitId
160 . '&addons=' . $addons
161 . '&head=78&body=68&legs=58&feet=76&direction=2';
162
163 if ($mountId > 0) {
164 $url .= '&mount=' . $mountId;
165 }
166
167 return $url;
168}
169
170function acp_shop_offer_preview(array $offer, array $items): string {
171 global $config;
172
173 $type = intv($offer['type'] ?? 0);
174 $itemId = intv($offer['itemid'] ?? 0);
175 $count = intv($offer['count'] ?? 0);
176
177 if ($type === 5 && !empty($config['show_outfits']['imageServer'])) {
178 $male = (int)floor($itemId / 10000);
179 $female = (int)($itemId % 10000);
180
181 return '<img class="acp-shop-preview-img" src="' . h(acp_shop_outfit_image_url($male, $count)) . '" alt="">'
182 . '<img class="acp-shop-preview-img" src="' . h(acp_shop_outfit_image_url($female, $count)) . '" alt="">';
183 }
184
185 if ($type === 6 && !empty($config['show_outfits']['imageServer'])) {
186 return '<img class="acp-shop-preview-img" src="' . h(acp_shop_outfit_image_url(128, 3, $itemId)) . '" alt="">';
187 }
188
189 if (!empty($config['shop']['showImage']) && $itemId > 0) {
190 return '<img class="acp-shop-preview-item" src="' . h(acp_shop_item_image_url($itemId)) . '" alt="">';
191 }
192
193 return '<span class="is-muted">' . h($items[$itemId] ?? '#' . $itemId) . '</span>';
194}
195
196if ($_SERVER['REQUEST_METHOD'] === 'POST') {
197 $shopAction = (string)($_POST['shop_offer_action'] ?? '');
198 $now = time();
199
200 if ($shopAction === 'add') {
201 $type = intv($_POST['type'] ?? 0);
202 $itemRaw = trim((string)($_POST['itemid'] ?? ''));
203 $count = max(0, intv($_POST['count'] ?? 0));
204 $points = max(1, intv($_POST['points'] ?? 0));
205 $sortOrder = intv($_POST['sort_order'] ?? 0);
206 $description = substr(trim((string)($_POST['description'] ?? '')), 0, 255);
207 $itemId = false;
208
209 if ($type === 5) {
210 $itemId = acp_shop_pack_outfit_pair($itemRaw);
211 if ($itemId === false) {
212 acp_flash_error(t('acp.shp.outfit_field_error'));
213 }
214 } elseif (preg_match('/^\d+$/', $itemRaw)) {
215 $itemId = (int)$itemRaw;
216 }
217
218 if ($type <= 0 || $description === '' || $points <= 0 || $itemId === false) {
219 acp_flash_error(t('acp.shp.missing_fields'));
220 } else {
221 $created = db()->execute("
222 INSERT INTO `znote_shop_offers`
223 (`type`, `itemid`, `count`, `description`, `points`, `active`, `sort_order`, `created_by`, `created_at`, `updated_at`)
224 VALUES
225 (?, ?, ?, ?, ?, 1, ?, ?, ?, ?);
226 ", [$type, $itemId, $count, $description, $points, $sortOrder, (int)$user_data['id'], $now, $now]);
227 if ($created) {
228 acp_log('shop.offer_add', $description, ['type' => $type, 'points' => $points]);
229 acp_flash_success(t('acp.shp.offer_added'));
230 } else {
231 acp_flash_error(t('acp.shp.offer_add_failed'));
232 }
233 acp_redirect('shop');
234 }
235 }
236
237 if ($shopAction === 'toggle') {
238 $id = intv($_POST['id'] ?? 0);
239 $updated = db()->execute("
240 UPDATE `znote_shop_offers`
241 SET `active` = CASE WHEN `active` = 1 THEN 0 ELSE 1 END,
242 `updated_at` = ?
243 WHERE `id` = ?
244 LIMIT 1;
245 ", [$now, $id]);
246 if ($updated) { acp_log('shop.offer_toggle', '#' . $id); }
247 $updated ? acp_flash_success(t('acp.shp.status_updated')) : acp_flash_error(t('acp.shp.status_failed'));
248 acp_redirect('shop');
249 }
250
251 if ($shopAction === 'delete') {
252 $id = intv($_POST['id'] ?? 0);
253 $deleted = db()->execute("DELETE FROM `znote_shop_offers` WHERE `id` = ? LIMIT 1;", [$id]);
254 if ($deleted) { acp_log('shop.offer_delete', '#' . $id); }
255 $deleted ? acp_flash_success(t('acp.shp.removed')) : acp_flash_error(t('acp.shp.remove_failed'));
256 acp_redirect('shop');
257 }
258
259 if (in_array($shopAction, ['bulk_enable', 'bulk_disable', 'bulk_delete'], true)) {
260 $ids = array_values(array_filter(array_map('intv', (array)($_POST['ids'] ?? []))));
261
262 if (!$ids) {
263 acp_flash_error(t('acp.shp.bulk_none_selected'));
264 acp_redirect('shop');
265 }
266
267 $placeholders = implode(',', array_fill(0, count($ids), '?'));
268
269 if ($shopAction === 'bulk_delete') {
270 $done = db()->execute("DELETE FROM `znote_shop_offers` WHERE `id` IN ({$placeholders});", $ids);
271 } else {
272 $active = ($shopAction === 'bulk_enable') ? 1 : 0;
273 $done = db()->execute("
274 UPDATE `znote_shop_offers` SET `active` = ?, `updated_at` = ? WHERE `id` IN ({$placeholders});
275 ", array_merge([$active, $now], $ids));
276 }
277
278 if ($done !== false) {
279 acp_log('shop.offer_bulk_' . $shopAction, implode(',', $ids), ['count' => count($ids)]);
280 acp_flash_success(t('acp.shp.bulk_done', ['n' => count($ids)]));
281 } else {
282 acp_flash_error(t('acp.shp.bulk_failed'));
283 }
284
285 acp_redirect('shop');
286 }
287}
288
289$offers = db()->fetchAll("SELECT * FROM `znote_shop_offers` ORDER BY `active` DESC, `sort_order` ASC, `id` ASC;");
290$offers = is_array($offers) ? $offers : [];
291$activeOffers = 0;
292$hiddenOffers = 0;
293foreach ($offers as $offer) {
294 !empty($offer['active']) ? $activeOffers++ : $hiddenOffers++;
295}
296
297$itemImageTemplate = acp_shop_item_image_url(999999);
298if ($itemImageTemplate !== '') {
299 $itemImageTemplate = str_replace('999999', '{id}', $itemImageTemplate);
300}
301?>
302
303<div class="acp-stats">
304 <?php
305 acp_stat(t('acp.shp.stat_db_offers'), count($offers), 'fa-tags', null, 'blue');
306 acp_stat(t('acp.shp.stat_active'), $activeOffers, 'fa-check-circle', null, 'green');
307 acp_stat(t('acp.shp.stat_hidden'), $hiddenOffers, 'fa-eye-slash', null, 'amber');
308 acp_stat(t('acp.shp.stat_orders'), acp_count("SELECT COUNT(*) AS `c` FROM `znote_shop_orders`;"), 'fa-history', acp_url('shop_orders'), 'purple');
309 ?>
310</div>
311
312<!-- ------------------------------------------------------- Add offer -->
313<section class="acp-card acp-shop-form-card">
314 <header class="acp-card-head">
315 <h2><?= h(t('acp.shp.add_offer_title')) ?></h2>
316 <p><?= h(t('acp.shp.add_offer_sub')) ?></p>
317 </header>
318 <div class="acp-card-body">
319 <form method="post">
320 <?= acp_csrf_field() ?>
321 <input type="hidden" name="shop_offer_action" value="add">
322
323 <div class="acp-row acp-shop-form-row">
324 <div class="acp-field">
325 <label class="acp-label" for="type"><?= h(t('acp.shp.type_label')) ?></label>
326 <select class="acp-select" id="type" name="type">
327 <option value="1"><?= h(t('acp.shp.type_item')) ?></option>
328 <option value="2"><?= h(t('acp.shp.type_premium')) ?></option>
329 <option value="3"><?= h(t('acp.shp.type_gender')) ?></option>
330 <option value="4"><?= h(t('acp.shp.type_name')) ?></option>
331 <option value="5"><?= h(t('acp.shp.outfit_option')) ?></option>
332 <option value="6"><?= h(t('acp.shp.type_mount')) ?></option>
333 <option value="8"><?= h(t('acp.shp.type_custom')) ?></option>
334 </select>
335 </div>
336 <div class="acp-field">
337 <label class="acp-label" for="itemid"><?= h(t('acp.shp.item_id')) ?></label>
338 <div class="acp-shop-id-field">
339 <input class="acp-input" id="itemid" name="itemid" placeholder="2160 or 128,136" required>
340 <div class="acp-shop-live-preview"
341 id="shopOfferPreview"
342 data-item-template="<?= h($itemImageTemplate) ?>"
343 data-outfit-server="<?= h(acp_shop_outfit_server_url()) ?>">
344 <span class="is-muted"><?= h(t('acp.shp.preview_label')) ?></span>
345 </div>
346 </div>
347 <p class="acp-hint"><?= h(t('acp.shp.outfit_hint')) ?></p>
348 </div>
349 <div class="acp-field">
350 <label class="acp-label" for="description"><?= h(t('acp.shp.description')) ?></label>
351 <input class="acp-input" id="description" name="description" maxlength="255" placeholder="5 x Crystal coin" required>
352 </div>
353 </div>
354
355 <div class="acp-row acp-shop-form-row">
356 <div class="acp-field">
357 <label class="acp-label" for="count"><?= h(t('acp.shp.count_label')) ?></label>
358 <input class="acp-input" id="count" name="count" type="number" min="0" value="1" required>
359 </div>
360 <div class="acp-field">
361 <label class="acp-label" for="points"><?= h(t('acp.shp.points')) ?></label>
362 <input class="acp-input" id="points" name="points" type="number" min="1" value="10" required>
363 </div>
364 <div class="acp-field">
365 <label class="acp-label" for="sort_order"><?= h(t('acp.shp.sort_order')) ?></label>
366 <input class="acp-input" id="sort_order" name="sort_order" type="number" value="0">
367 </div>
368 </div>
369
370 <div class="acp-actions">
371 <button class="acp-btn acp-btn--green" type="submit"><i class="fa fa-plus"></i> <?= h(t('acp.shp.add_offer_btn')) ?></button>
372 </div>
373 </form>
374 </div>
375</section>
376
377<!-- ------------------------------------------------------- Current offers -->
378<section class="acp-card acp-shop-offers-card">
379 <header class="acp-card-head">
380 <h2><?= h(t('acp.shp.db_offers_title')) ?></h2>
381 <p><?= h(t('acp.shp.db_offers_sub')) ?></p>
382 </header>
383 <div class="acp-card-body is-flush">
384 <?php if ($offers): ?>
385 <form id="acpShopBulkForm" method="post">
386 <?= acp_csrf_field() ?>
387 <input type="hidden" name="shop_offer_action" id="acpShopBulkAction" value="">
388 </form>
389
390 <div class="acp-toolbar" id="acpShopBulkBar" hidden>
391 <span class="is-muted"><span id="acpShopBulkCount">0</span> <?= h(t_default('acp.shp.bulk_selected', 'selected')) ?></span>
392 <div class="acp-actions is-tight">
393 <button type="submit" form="acpShopBulkForm" class="acp-btn acp-btn--sm"
394 onclick="document.getElementById('acpShopBulkAction').value='bulk_enable';">
395 <i class="fa fa-eye"></i> <?= h(t_default('acp.shp.bulk_enable', 'Show selected')) ?>
396 </button>
397 <button type="submit" form="acpShopBulkForm" class="acp-btn acp-btn--ghost acp-btn--sm"
398 onclick="document.getElementById('acpShopBulkAction').value='bulk_disable';">
399 <i class="fa fa-eye-slash"></i> <?= h(t_default('acp.shp.bulk_disable', 'Hide selected')) ?>
400 </button>
401 <button type="submit" form="acpShopBulkForm" class="acp-btn acp-btn--red acp-btn--sm"
402 onclick="document.getElementById('acpShopBulkAction').value='bulk_delete'; return confirm('<?= h(t_default('acp.shp.bulk_confirm_delete', 'Delete every selected offer? This cannot be undone.')) ?>');">
403 <i class="fa fa-trash"></i> <?= h(t_default('acp.shp.bulk_delete', 'Delete selected')) ?>
404 </button>
405 </div>
406 </div>
407 <?php if (count($offers) > 8): ?>
408 <div class="acp-toolbar">
409 <div style="display:flex;gap:8px;flex:1 1 320px;max-width:460px;">
410 <input class="acp-input" type="search" data-acp-search-input="acpShopOfferTable"
411 placeholder="<?= h(t_default('acp.shp.search_placeholder', 'Search offers...')) ?>">
412 </div>
413 <span class="is-muted" data-acp-search-count="acpShopOfferTable"></span>
414 </div>
415 <?php endif; ?>
416 <div class="acp-table-wrap">
417 <table class="acp-table acp-shop-offers-table" data-sortable id="acpShopOfferTable">
418 <thead>
419 <tr>
420 <th><input type="checkbox" id="acpShopBulkAll" aria-label="<?= h(t_default('acp.shp.bulk_select_all', 'Select all')) ?>"></th>
421 <th>#</th>
422 <th><?= h(t('acp.shp.col_preview')) ?></th>
423 <th><?= h(t('acp.shp.col_offer')) ?></th>
424 <th class="is-num"><?= h(t('acp.shp.col_points')) ?></th>
425 <th><?= h(t('acp.shp.col_status')) ?></th>
426 <th class="is-num"><?= h(t('acp.shp.col_actions')) ?></th>
427 </tr>
428 </thead>
429 <tbody>
430 <?php foreach ($offers as $offer): ?>
431 <tr data-acp-search="<?= h(strtolower((string)($offer['description'] ?? ''))) ?>">
432 <td><input type="checkbox" class="acp-shop-bulk-check" form="acpShopBulkForm" name="ids[]" value="<?= intv($offer['id'] ?? 0) ?>"></td>
433 <td class="is-muted"><?= intv($offer['id'] ?? 0) ?></td>
434 <td><?= acp_shop_offer_preview($offer, $items) ?></td>
435 <td>
436 <strong><?= h($offer['description'] ?? '') ?></strong><br>
437 <span class="is-muted">
438 <?= h(acp_shop_offer_type_label(intv($offer['type'] ?? 0))) ?> -
439 <?= intv($offer['type'] ?? 0) === 5 ? h(acp_shop_unpack_outfit_pair(intv($offer['itemid'] ?? 0))) : intv($offer['itemid'] ?? 0) ?>,
440 <?= h(t('acp.shp.count_label2')) ?> <?= intv($offer['count'] ?? 0) ?>
441 </span>
442 </td>
443 <td class="is-num"><strong><?= intv($offer['points'] ?? 0) ?></strong></td>
444 <td>
445 <span class="acp-pill <?= !empty($offer['active']) ? 'acp-pill--green' : 'acp-pill--grey' ?>">
446 <?= !empty($offer['active']) ? h(t('acp.shp.active_pill')) : h(t('acp.shp.hidden_pill')) ?>
447 </span>
448 </td>
449 <td class="is-nowrap is-num acp-shop-offer-actions">
450 <form class="acp-inline-form" method="post">
451 <?= acp_csrf_field() ?>
452 <input type="hidden" name="shop_offer_action" value="toggle">
453 <input type="hidden" name="id" value="<?= intv($offer['id'] ?? 0) ?>">
454 <button class="acp-btn acp-btn--ghost acp-btn--sm" type="submit">
455 <?= !empty($offer['active']) ? h(t('acp.shp.hide')) : h(t('acp.shp.show')) ?>
456 </button>
457 </form>
458 <form class="acp-inline-form" method="post" data-confirm="<?= h(t('acp.shp.confirm_remove')) ?>">
459 <?= acp_csrf_field() ?>
460 <input type="hidden" name="shop_offer_action" value="delete">
461 <input type="hidden" name="id" value="<?= intv($offer['id'] ?? 0) ?>">
462 <button class="acp-btn acp-btn--red acp-btn--sm" type="submit" title="<?= h(t('acp.shp.remove_offer')) ?>" aria-label="<?= h(t('acp.shp.remove_offer')) ?>"><i class="fa fa-times"></i></button>
463 </form>
464 </td>
465 </tr>
466 <?php endforeach; ?>
467 </tbody>
468 </table>
469 </div>
470 <?php else: ?>
471 <?php acp_empty(t('acp.shp.empty'), 'fa-tags'); ?>
472 <?php endif; ?>
473 </div>
474</section>
475
476<script>
477(function () {
478 var all = document.getElementById('acpShopBulkAll');
479 var boxes = Array.prototype.slice.call(document.querySelectorAll('.acp-shop-bulk-check'));
480 var bar = document.getElementById('acpShopBulkBar');
481 var count = document.getElementById('acpShopBulkCount');
482 if (!all || !bar) return;
483
484 function refresh() {
485 var checked = boxes.filter(function (b) { return b.checked; });
486 bar.hidden = checked.length === 0;
487 if (count) count.textContent = checked.length;
488 all.checked = checked.length > 0 && checked.length === boxes.length;
489 all.indeterminate = checked.length > 0 && checked.length < boxes.length;
490 }
491
492 all.addEventListener('change', function () {
493 boxes.forEach(function (b) { if (!b.closest('tr').hidden) b.checked = all.checked; });
494 refresh();
495 });
496 boxes.forEach(function (b) { b.addEventListener('change', refresh); });
497})();
498</script>
499