@necrolab/dashboard 0.5.28 → 0.5.29

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.
Files changed (38) hide show
  1. package/backend/api.js +7 -5
  2. package/backend/batching.js +59 -2
  3. package/backend/index.js +1 -1
  4. package/index.html +10 -23
  5. package/package.json +1 -1
  6. package/src/assets/css/base/scroll.scss +1 -1
  7. package/src/assets/css/main.scss +14 -14
  8. package/src/components/Console/ConsoleToolbar.vue +8 -8
  9. package/src/components/Editors/Account/Account.vue +9 -5
  10. package/src/components/Editors/Account/AccountView.vue +37 -18
  11. package/src/components/Editors/Account/CreateAccount.vue +38 -4
  12. package/src/components/Editors/Profile/CreateProfile.vue +29 -4
  13. package/src/components/Editors/Profile/Profile.vue +11 -6
  14. package/src/components/Editors/Profile/ProfileCountryChooser.vue +2 -2
  15. package/src/components/Editors/Profile/ProfileView.vue +37 -18
  16. package/src/components/Tasks/CreateTaskAXS.vue +16 -2
  17. package/src/components/Tasks/CreateTaskTM.vue +28 -5
  18. package/src/components/Tasks/QuickSettings.vue +77 -10
  19. package/src/components/Tasks/Task.vue +20 -7
  20. package/src/components/Tasks/TaskView.vue +144 -58
  21. package/src/components/Tasks/ViewTask.vue +17 -3
  22. package/src/components/ui/Modal.vue +1 -1
  23. package/src/components/ui/ReadonlyFieldsSection.vue +3 -3
  24. package/src/components/ui/StatusBadge.vue +1 -1
  25. package/src/components/ui/TaskToggle.vue +2 -3
  26. package/src/components/ui/controls/CountryChooser.vue +2 -2
  27. package/src/components/ui/controls/atomic/Dropdown.vue +2 -2
  28. package/src/components/ui/controls/atomic/MultiDropdown.vue +1 -1
  29. package/src/composables/useDynamicTableHeight.js +4 -4
  30. package/src/composables/useRowSelection.js +0 -1
  31. package/src/composables/useZoomPrevention.js +16 -55
  32. package/src/stores/connection.js +453 -68
  33. package/src/stores/sampleData.js +34 -24
  34. package/src/stores/ui.js +89 -100
  35. package/src/views/Accounts.vue +2 -5
  36. package/src/views/Console.vue +13 -14
  37. package/src/views/Profiles.vue +2 -5
  38. package/vite.config.js +4 -2
@@ -33,19 +33,23 @@
33
33
  <h4 class="hidden text-white md:flex md:ml-3">Actions</h4>
34
34
  </div>
35
35
  </Header>
36
- <div
37
- v-if="toRender.length != 0"
38
- class="hidden-scrollbars flex flex-col divide-y divide-dark-650 overflow-y-auto overflow-x-hidden transition-colors duration-150 table-scroll"
39
- :style="{ maxHeight: dynamicTableHeight }">
40
- <div
41
- v-for="(profile, i) in toRender"
42
- :key="profile.id || profile.index"
43
- class="min-h-16 flex-shrink-0 hover:bg-dark-550">
44
- <Profile
45
- :class="getRowClass(i)"
46
- :profile="profile" />
47
- </div>
48
- </div>
36
+ <RecycleScroller
37
+ v-if="props.profiles.length !== 0"
38
+ class="hidden-scrollbars touch-pan-y overflow-y-auto overflow-x-hidden transition-colors duration-150 table-scroll scrollable"
39
+ :style="{ height: dynamicTableHeight, maxHeight: dynamicTableHeight }"
40
+ :items="virtualProfiles"
41
+ key-field="virtualKey"
42
+ :item-size="64"
43
+ @wheel.passive="handleVirtualWheel">
44
+ <template #default="{ item, index }">
45
+ <div class="min-h-16 flex-shrink-0 hover:bg-dark-550">
46
+ <Profile
47
+ :class="getRowClass(index)"
48
+ :profile="item.profile"
49
+ :privacy="props.privacy" />
50
+ </div>
51
+ </template>
52
+ </RecycleScroller>
49
53
  <EmptyState v-else :icon="ProfileIcon" message="No profiles found" subtitle="Create profiles to get started" />
50
54
  </div>
51
55
  </template>
@@ -57,22 +61,37 @@ import Checkbox from "@/components/ui/controls/atomic/Checkbox.vue";
57
61
  import EmptyState from "@/components/ui/EmptyState.vue";
58
62
  import { useUIStore } from "@/stores/ui";
59
63
  import { useDynamicTableHeight } from "@/composables/useDynamicTableHeight";
60
- import { computed } from "vue";
61
- import { useTableRender } from "@/composables/useTableRender";
62
64
  import { getRowClass } from "@/utils/tableHelpers";
65
+ import { RecycleScroller } from "vue-virtual-scroller";
66
+ import { computed } from "vue";
63
67
 
64
68
  const props = defineProps({
65
69
  profiles: {
66
- type: Object,
70
+ type: Array,
67
71
  required: true
72
+ },
73
+ privacy: {
74
+ type: Boolean,
75
+ default: true
68
76
  }
69
77
  });
70
78
 
71
79
  const ui = useUIStore();
72
80
 
73
- const { toRender } = useTableRender(computed(() => props.profiles));
74
-
75
81
  import { TABLE_LAYOUT } from "@/constants/tableLayout";
76
82
 
77
83
  const { dynamicTableHeight } = useDynamicTableHeight(TABLE_LAYOUT.PROFILES);
84
+
85
+ const virtualProfiles = computed(() =>
86
+ props.profiles.map((profile) => ({
87
+ profile,
88
+ virtualKey: String(profile.id ?? `${profile.profileName ?? "profile"}-${profile.cardNumber ?? ""}`)
89
+ }))
90
+ );
91
+
92
+ const handleVirtualWheel = (event) => {
93
+ const target = event.currentTarget;
94
+ if (!target) return;
95
+ target.scrollTop += event.deltaY;
96
+ };
78
97
  </script>
@@ -108,6 +108,7 @@ import {
108
108
  StadiumIcon,
109
109
  ScannerIcon,
110
110
  BagIcon,
111
+ HashIcon,
111
112
  TagIcon,
112
113
  SkiIcon,
113
114
  HandIcon,
@@ -131,7 +132,7 @@ const defaultTags = ["Amex", "Visa", "Master"];
131
132
  const profileTagsOptions = ref(
132
133
  removeDuplicates(["Any", ...defaultTags, ...ui.profile.profileTags.map((x) => firstUpper(x))])
133
134
  );
134
- const baseTask = ref({
135
+ const createBaseTask = () => ({
135
136
  selected: false,
136
137
  taskId: "",
137
138
  active: false,
@@ -153,7 +154,20 @@ const baseTask = ref({
153
154
  promoId: ""
154
155
  });
155
156
 
156
- const task = ref(ui.modalData[`task_${ui.currentCountry.siteId}`] || baseTask);
157
+ const booleanTaskFields = ["manual", "doNotPay", "quickQueue", "smartTimer"];
158
+
159
+ const sanitizeTaskState = (savedState = {}) => {
160
+ const defaults = createBaseTask();
161
+ const merged = { ...defaults, ...savedState };
162
+
163
+ for (const field of booleanTaskFields) {
164
+ merged[field] = typeof merged[field] === "boolean" ? merged[field] : defaults[field];
165
+ }
166
+
167
+ return merged;
168
+ };
169
+
170
+ const task = ref(sanitizeTaskState(ui.modalData[`task_${ui.currentCountry.siteId}`]));
157
171
 
158
172
  function createTask() {
159
173
  ui.logger.Info("Created new task", task.value.taskId);
@@ -136,6 +136,7 @@ import {
136
136
  StadiumIcon,
137
137
  ScannerIcon,
138
138
  BagIcon,
139
+ HashIcon,
139
140
  TagIcon,
140
141
  SkiIcon,
141
142
  HandIcon,
@@ -164,7 +165,7 @@ const profileTagsOptions = ref(
164
165
  removeDuplicates(["Any", ...defaultTags, ...ui.profile.profileTags.map((x) => firstUpper(x))])
165
166
  );
166
167
 
167
- const baseTask = ref({
168
+ const createBaseTask = () => ({
168
169
  selected: false,
169
170
  taskId: "",
170
171
  active: false,
@@ -179,10 +180,12 @@ const baseTask = ref({
179
180
  manual: true,
180
181
  doNotPay: false,
181
182
  quickQueue: false,
182
- loginAfterCart: false,
183
+ loginAfterCart: isEU(ui.currentCountry.siteId),
183
184
  smartTimer: false,
184
185
  presaleMode: false,
186
+ presaleStrict: false,
185
187
  agedAccount: false,
188
+ otpAccount: false,
186
189
  accountTag: accountTagOptions.value[0],
187
190
  profileTags: ["Any"],
188
191
  clOrigin: undefined,
@@ -190,9 +193,30 @@ const baseTask = ref({
190
193
  taskQuantity: 1
191
194
  });
192
195
 
193
- baseTask.value.loginAfterCart = isEU(ui.currentCountry.siteId);
196
+ const booleanTaskFields = [
197
+ "manual",
198
+ "doNotPay",
199
+ "quickQueue",
200
+ "loginAfterCart",
201
+ "smartTimer",
202
+ "presaleMode",
203
+ "presaleStrict",
204
+ "agedAccount",
205
+ "otpAccount"
206
+ ];
194
207
 
195
- const task = ref(ui.modalData[`task_${ui.currentCountry.siteId}`] || baseTask);
208
+ const sanitizeTaskState = (savedState = {}) => {
209
+ const defaults = createBaseTask();
210
+ const merged = { ...defaults, ...savedState };
211
+
212
+ for (const field of booleanTaskFields) {
213
+ merged[field] = typeof merged[field] === "boolean" ? merged[field] : defaults[field];
214
+ }
215
+
216
+ return merged;
217
+ };
218
+
219
+ const task = ref(sanitizeTaskState(ui.modalData[`task_${ui.currentCountry.siteId}`]));
196
220
 
197
221
  function createTask() {
198
222
  ui.logger.Info("Created new task", task.value.taskId);
@@ -251,4 +275,3 @@ watch(
251
275
  }
252
276
  );
253
277
  </script>
254
-
@@ -15,7 +15,7 @@
15
15
  <div class="w-2/3 h-full relative">
16
16
  <input
17
17
  type="text"
18
- class="api-key-input pr-16"
18
+ class="api-key-input"
19
19
  :placeholder="`Enter ${keyName} key`"
20
20
  v-model="quickConfig.keys[categoryName][keyName]"
21
21
  />
@@ -33,7 +33,7 @@
33
33
  </div>
34
34
  <div v-else-if="balanceState[categoryName]?.[keyName]?.balance !== null && balanceState[categoryName]?.[keyName]?.balance !== undefined" class="flex-gap-1 items-center text-green-400" :title="`Balance: ${balanceState[categoryName]?.[keyName]?.balance}`">
35
35
  <img src="@/assets/img/sell.svg" width="9" class="opacity-60" />
36
- <span class="text-2xs font-bold tabular-nums">{{ formatBalance(balanceState[categoryName]?.[keyName]?.balance) }}</span>
36
+ <span class="text-2xs font-bold tabular-nums balance-value">{{ formatBalance(balanceState[categoryName]?.[keyName]?.balance) }}</span>
37
37
  </div>
38
38
  </div>
39
39
  </div>
@@ -154,7 +154,7 @@
154
154
 
155
155
  .api-key-input {
156
156
  @apply w-full h-full text-sm text-white border-0 rounded-md;
157
- @apply px-3 py-2;
157
+ @apply px-3 py-2 pr-24;
158
158
  @apply focus:outline-none;
159
159
  background: oklch(26% 0 68deg);
160
160
 
@@ -167,6 +167,13 @@
167
167
  @apply absolute right-3 top-1/2 -translate-y-1/2;
168
168
  @apply flex items-center justify-center;
169
169
  @apply pointer-events-none;
170
+ max-width: 5.25rem;
171
+ min-width: 1rem;
172
+ }
173
+
174
+ .balance-value {
175
+ @apply truncate inline-block align-middle;
176
+ max-width: 4.25rem;
170
177
  }
171
178
  </style>
172
179
  <script setup>
@@ -245,11 +252,7 @@ const checkBalances = async () => {
245
252
  // Match service name (case-insensitive)
246
253
  if (keyName.toLowerCase().includes(service.toLowerCase()) ||
247
254
  service.toLowerCase().includes(keyName.toLowerCase())) {
248
- balanceState.value[categoryName][keyName] = {
249
- loading: false,
250
- error: false,
251
- balance: balance
252
- };
255
+ balanceState.value[categoryName][keyName] = normalizeBalanceState(balance);
253
256
  }
254
257
  });
255
258
  });
@@ -285,7 +288,7 @@ const checkBalances = async () => {
285
288
  };
286
289
 
287
290
  const formatBalance = (balance) => {
288
- if (typeof balance === 'number') {
291
+ if (typeof balance === 'number' && Number.isFinite(balance)) {
289
292
  // Format large numbers with K, M suffixes if needed
290
293
  if (balance >= 1000000) {
291
294
  return (balance / 1000000).toFixed(1) + 'M';
@@ -296,7 +299,71 @@ const formatBalance = (balance) => {
296
299
  }
297
300
  return balance.toLocaleString();
298
301
  }
299
- return String(balance);
302
+ return "--";
303
+ };
304
+
305
+ const normalizeBalanceState = (rawBalance) => {
306
+ if (typeof rawBalance === "number" && Number.isFinite(rawBalance)) {
307
+ return {
308
+ loading: false,
309
+ error: false,
310
+ balance: rawBalance
311
+ };
312
+ }
313
+
314
+ if (typeof rawBalance === "string") {
315
+ const cleaned = rawBalance.trim();
316
+ const lower = cleaned.toLowerCase();
317
+ const isErrorLike =
318
+ lower.includes("could not check balance") ||
319
+ lower.includes("could not") ||
320
+ lower.includes("failed") ||
321
+ lower.includes("error") ||
322
+ lower.includes("invalid");
323
+
324
+ if (isErrorLike) {
325
+ return {
326
+ loading: false,
327
+ error: true,
328
+ balance: null
329
+ };
330
+ }
331
+
332
+ const parsed = Number(cleaned.replace(/[$,\s]/g, ""));
333
+ if (Number.isFinite(parsed)) {
334
+ return {
335
+ loading: false,
336
+ error: false,
337
+ balance: parsed
338
+ };
339
+ }
340
+ }
341
+
342
+ if (rawBalance && typeof rawBalance === "object") {
343
+ const objectError = rawBalance.error || rawBalance.message || rawBalance.status;
344
+ if (objectError) {
345
+ return {
346
+ loading: false,
347
+ error: true,
348
+ balance: null
349
+ };
350
+ }
351
+
352
+ const nestedBalance = rawBalance.balance ?? rawBalance.value;
353
+ if (typeof nestedBalance === "number" && Number.isFinite(nestedBalance)) {
354
+ return {
355
+ loading: false,
356
+ error: false,
357
+ balance: nestedBalance
358
+ };
359
+ }
360
+ }
361
+
362
+ return {
363
+ loading: false,
364
+ error: true,
365
+ balance: null
366
+ };
300
367
  };
301
368
 
302
369
  loadProxyLists();
@@ -3,8 +3,8 @@
3
3
  class="relative min-h-full grid-cols-10 gap-2 text-white lg:grid-cols-12"
4
4
  @click="ui.setOpenContextMenu('')"
5
5
  @dblclick="handleDoubleClick"
6
- @touchstart="handleTouchStart"
7
- @touchend="handleTouchEnd">
6
+ @touchstart.passive="handleTouchStart"
7
+ @touchend.passive="handleTouchEnd">
8
8
  <div class="col-span-1 flex items-center justify-start py-2 lg:col-span-2">
9
9
  <Checkbox
10
10
  class="ml-2 mr-4 flex-shrink-0"
@@ -109,10 +109,9 @@
109
109
  <span
110
110
  class="mt-1 block text-xs font-bold"
111
111
  :class="{
112
- 'text-red-400':
113
- props.task._timeLeftString === '00:00' || props.task._timeLeftString === 'No Cartholds'
112
+ 'text-red-400': taskTimeLeft === '00:00' || taskTimeLeft === 'No Cartholds'
114
113
  }">
115
- {{ props.task._timeLeftString !== "00:00" ? props.task._timeLeftString : "Expired" }}
114
+ {{ taskTimeLeft !== "00:00" ? taskTimeLeft : "Expired" }}
116
115
  </span>
117
116
  </h4>
118
117
  </div>
@@ -144,7 +143,7 @@
144
143
  <PlayIcon />
145
144
  </button>
146
145
  </li>
147
- <li v-if="task.status?.toLowerCase() == 'waiting' && props.task._timeLeftString !== '00:00'">
146
+ <li v-if="task.status?.toLowerCase() == 'waiting' && taskTimeLeft !== '00:00'">
148
147
  <button @click="ui.continueTask(task.taskId, 'autocheckout')" aria-label="Auto checkout">
149
148
  <BagWhiteIcon />
150
149
  </button>
@@ -203,12 +202,13 @@ import Checkbox from "@/components/ui/controls/atomic/Checkbox.vue";
203
202
  import ActionButtonGroup from "@/components/ui/ActionButtonGroup.vue";
204
203
  import EventDetailRow from "@/components/Tasks/EventDetailRow.vue";
205
204
  import { useUIStore } from "@/stores/ui";
206
- import { ref, onUnmounted, nextTick } from "vue";
205
+ import { computed, ref, onUnmounted, nextTick } from "vue";
207
206
  import { useRowSelection } from "@/composables/useRowSelection";
208
207
  import { useCopyToClipboard } from "@/composables/useCopyToClipboard";
209
208
  import { useColorMapping } from "@/composables/useColorMapping";
210
209
  import { useDateFormatting } from "@/composables/useDateFormatting";
211
210
  import { useTicketPricing } from "@/composables/useTicketPricing";
211
+ import { timeDifference } from "@/libs/utils/time";
212
212
 
213
213
  const ui = useUIStore();
214
214
  const { copy } = useCopyToClipboard();
@@ -237,6 +237,19 @@ const { handleDoubleClick, handleTouchStart, handleTouchEnd } = useRowSelection(
237
237
  ui.toggleTaskSelected(props.task.taskId);
238
238
  });
239
239
 
240
+ const taskTimeLeft = computed(() => {
241
+ const now = ui.taskTimeTick || Date.now();
242
+ if (!props.task.expirationTime) return props.task._timeLeftString || "";
243
+ if (props.task.expirationTime === "Invalid Date") return "No Cartholds";
244
+
245
+ const asNumber = Number(props.task.expirationTime);
246
+ const expirationTimestamp = Number.isFinite(asNumber) && asNumber > 0
247
+ ? asNumber
248
+ : Date.parse(props.task.expirationTime);
249
+ if (Number.isNaN(expirationTimestamp)) return props.task._timeLeftString || "00:00";
250
+ return timeDifference(expirationTimestamp, now);
251
+ });
252
+
240
253
  const MENU_WIDTH = 168;
241
254
  const MENU_HEIGHT = 200;
242
255
 
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="table-component relative box-border flex flex-col rounded-lg bg-dark-500 bg-clip-padding overflow-hidden shadow-sm">
2
+ <div class="table-component relative box-border flex flex-col rounded-lg bg-dark-500 bg-clip-padding overflow-x-hidden shadow-sm">
3
3
  <Header class="grid-cols-10 gap-2 text-center lg:grid-cols-12">
4
4
  <div class="col-span-1 flex items-center justify-start lg:col-span-2">
5
5
  <Checkbox
@@ -34,28 +34,54 @@
34
34
  <h4 class="text-center text-xs text-white">ID</h4>
35
35
  </div>
36
36
  </Header>
37
+ <DynamicScroller
38
+ v-if="virtualTaskItems.length"
39
+ class="hidden-scrollbars touch-pan-y min-h-0 overflow-y-auto overflow-x-hidden scrollable"
40
+ :style="{ height: dynamicTableHeight, maxHeight: dynamicTableHeight }"
41
+ :items="virtualTaskItems"
42
+ :min-item-size="virtualMinItemSize"
43
+ :buffer="virtualBuffer"
44
+ @wheel.passive="handleVirtualWheel"
45
+ key-field="taskId">
46
+ <template #default="{ item, index, active }">
47
+ <DynamicScrollerItem
48
+ :item="item"
49
+ :active="active"
50
+ :size-dependencies="[
51
+ props.tasks[item.taskId]?.status,
52
+ props.tasks[item.taskId]?.expirationTime,
53
+ ui.taskTimeTick,
54
+ props.tasks[item.taskId]?.reservedTicketsList,
55
+ props.tasks[item.taskId]?.eventName
56
+ ]"
57
+ :data-index="index">
58
+ <div
59
+ class="shrink-0 border-b border-dark-650 min-h-14.5 md:min-h-17.25 has-[.event-details]:min-h-18.75 mobile-portrait:min-h-12.5 transition-colors duration-150 ease-in-out hover:!bg-dark-550">
60
+ <Task
61
+ v-if="props.tasks[item.taskId]"
62
+ :task="props.tasks[item.taskId]"
63
+ :preferEventName="props.preferEventName"
64
+ :class="getRowClass(index)" />
65
+ </div>
66
+ </DynamicScrollerItem>
67
+ </template>
68
+ </DynamicScroller>
37
69
  <div
38
- class="hidden-scrollbars touch-pan-y flex flex-col divide-y divide-dark-650 overflow-y-auto overflow-x-hidden"
39
- :style="{ maxHeight: dynamicTableHeight }">
40
- <div v-for="(task, i) in getTasksInOrder()" :key="task.taskId" class="shrink-0 min-h-14.5 md:min-h-17.25 has-[.event-details]:min-h-18.75 mobile-portrait:min-h-12.5 transition-colors duration-150 ease-in-out hover:!bg-dark-550">
41
- <Task :task="task" :preferEventName="props.preferEventName" :class="getRowClass(i)" />
42
- </div>
70
+ v-else
71
+ class="empty-state flex flex-col items-center justify-center py-8 text-center bg-dark-400 text-light-500 text-sm font-medium"
72
+ :style="{ minHeight: dynamicTableHeight, maxHeight: dynamicTableHeight }">
43
73
  <div
44
- v-if="getTasksInOrder().length === 0"
45
- class="empty-state flex flex-col items-center justify-center py-8 text-center bg-dark-400 text-light-500 text-sm font-medium">
46
- <div
47
- v-if="
48
- !ui.queueStats.queued && !ui.queueStats.sleeping && ui.queueStats.nextQueuePasses.length === 0
49
- ">
50
- <TasksIcon class="mx-auto empty-state-icon" />
51
- <p class="text-sm text-light-400">No tasks yet</p>
52
- <p class="mt-1 text-xs text-light-500">Create tasks to get started</p>
53
- </div>
54
- <div v-else>
55
- <TasksIcon class="mx-auto empty-state-icon" />
56
- <p class="text-sm text-light-400">No tasks match current filters</p>
57
- <p class="mt-1 text-xs text-light-500">Adjust filters to see tasks</p>
58
- </div>
74
+ v-if="
75
+ !ui.queueStats.queued && !ui.queueStats.sleeping && ui.queueStats.nextQueuePasses.length === 0
76
+ ">
77
+ <TasksIcon class="mx-auto empty-state-icon" />
78
+ <p class="text-sm text-light-400">No tasks yet</p>
79
+ <p class="mt-1 text-xs text-light-500">Create tasks to get started</p>
80
+ </div>
81
+ <div v-else>
82
+ <TasksIcon class="mx-auto empty-state-icon" />
83
+ <p class="text-sm text-light-400">No tasks match current filters</p>
84
+ <p class="mt-1 text-xs text-light-500">Adjust filters to see tasks</p>
59
85
  </div>
60
86
  </div>
61
87
  </div>
@@ -68,6 +94,7 @@ import Task from "./Task.vue";
68
94
  import Checkbox from "@/components/ui/controls/atomic/Checkbox.vue";
69
95
  import { useUIStore } from "@/stores/ui";
70
96
  import { getRowClass } from "@/utils/tableHelpers";
97
+ import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
71
98
 
72
99
  const props = defineProps({
73
100
  tasks: {
@@ -99,47 +126,97 @@ const siteIdEdgeCases = {
99
126
  TM_NZ: ["TM_AU"]
100
127
  };
101
128
 
102
- const getTasksInOrder = () => {
103
- let out = [];
129
+ const virtualItemCache = new Map();
130
+ const searchTextCache = new Map();
131
+
132
+ const shouldTaskMatchSearch = (task, searchLower) => {
133
+ if (!searchLower) return true;
134
+
135
+ const cached = searchTextCache.get(task.taskId);
136
+ if (
137
+ cached &&
138
+ cached.eventId === task.eventId &&
139
+ cached.eventName === task.eventName &&
140
+ cached.eventVenue === task.eventVenue &&
141
+ cached.email === task.email &&
142
+ cached.status === task.status &&
143
+ cached.profileName === task.profileName &&
144
+ cached.presaleCode === task.presaleCode &&
145
+ cached.reservedTicketsList === task.reservedTicketsList
146
+ ) {
147
+ return cached.text.includes(searchLower);
148
+ }
149
+
150
+ const searchableText = [
151
+ task.eventId,
152
+ task.eventName,
153
+ task.eventVenue,
154
+ task.email,
155
+ task.taskId,
156
+ task.status,
157
+ task.profileName,
158
+ task.presaleCode,
159
+ task.reservedTicketsList
160
+ ]
161
+ .filter(Boolean)
162
+ .join(" ")
163
+ .toLowerCase();
164
+
165
+ searchTextCache.set(task.taskId, {
166
+ eventId: task.eventId,
167
+ eventName: task.eventName,
168
+ eventVenue: task.eventVenue,
169
+ email: task.email,
170
+ status: task.status,
171
+ profileName: task.profileName,
172
+ presaleCode: task.presaleCode,
173
+ reservedTicketsList: task.reservedTicketsList,
174
+ text: searchableText
175
+ });
176
+
177
+ return searchableText.includes(searchLower);
178
+ };
179
+
180
+ const filteredTaskIds = computed(() => {
181
+ const out = [];
104
182
  const searchLower = props.searchQuery?.toLowerCase().trim() || "";
105
183
 
106
- ui.taskIdOrder.forEach((id) => {
107
- if (props.tasks[id] && !props.tasks[id]?.hidden) {
108
- const task = props.tasks[id];
109
-
110
- if (
111
- task.siteId !== ui.currentCountry.siteId &&
112
- !siteIdEdgeCases[task.siteId]?.includes(ui.currentCountry.siteId)
113
- )
114
- return;
115
- if (ui.currentEvent && task.eventId !== ui.currentEvent) return;
116
- if (!shouldTaskShow(task)) return;
117
-
118
- // Search filter
119
- if (searchLower) {
120
- const searchableText = [
121
- task.eventId,
122
- task.eventName,
123
- task.eventVenue,
124
- task.email,
125
- task.taskId,
126
- task.status,
127
- task.profileName,
128
- task.presaleCode,
129
- task.reservedTicketsList
130
- ]
131
- .filter(Boolean)
132
- .join(" ")
133
- .toLowerCase();
134
-
135
- if (!searchableText.includes(searchLower)) return;
136
- }
137
-
138
- out.push(task);
184
+ for (const id of ui.taskIdOrder) {
185
+ const task = props.tasks[id];
186
+ if (!task || task.hidden) {
187
+ searchTextCache.delete(id);
188
+ continue;
139
189
  }
140
- });
190
+
191
+ if (task.siteId !== ui.currentCountry.siteId && !siteIdEdgeCases[task.siteId]?.includes(ui.currentCountry.siteId)) continue;
192
+ if (ui.currentEvent && task.eventId !== ui.currentEvent) continue;
193
+ if (!shouldTaskShow(task)) continue;
194
+ if (!shouldTaskMatchSearch(task, searchLower)) continue;
195
+
196
+ out.push(id);
197
+ }
198
+
141
199
  return out;
142
- };
200
+ });
201
+
202
+ const virtualTaskItems = computed(() => {
203
+ const idsInUse = new Set(filteredTaskIds.value);
204
+ for (const cachedId of virtualItemCache.keys()) {
205
+ if (!idsInUse.has(cachedId)) virtualItemCache.delete(cachedId);
206
+ }
207
+ for (const cachedId of searchTextCache.keys()) {
208
+ if (!idsInUse.has(cachedId) && !props.tasks[cachedId]) searchTextCache.delete(cachedId);
209
+ }
210
+
211
+ return filteredTaskIds.value.map((taskId) => {
212
+ let item = virtualItemCache.get(taskId);
213
+ if (!item) {
214
+ item = { taskId };
215
+ virtualItemCache.set(taskId, item);
216
+ }
217
+ return item;
218
+ });
219
+ });
143
220
 
144
221
  // Dynamic height calculation to prevent page scrolling
145
222
  const windowHeight = ref(window.innerHeight);
@@ -158,6 +235,15 @@ onUnmounted(() => {
158
235
  window.removeEventListener("resize", updateDimensions);
159
236
  });
160
237
 
238
+ const virtualMinItemSize = computed(() => (windowWidth.value <= 768 ? 58 : 69));
239
+ const virtualBuffer = computed(() => virtualMinItemSize.value * 8);
240
+
241
+ const handleVirtualWheel = (event) => {
242
+ const target = event.currentTarget;
243
+ if (!target) return;
244
+ target.scrollTop += event.deltaY;
245
+ };
246
+
161
247
  const dynamicTableHeight = computed(() => {
162
248
  // Detect PWA mode (standalone display)
163
249
  const isPWA = window.matchMedia('(display-mode: standalone)').matches;
@@ -47,11 +47,11 @@
47
47
  <span>{{ taskSnapshot.status }}</span>
48
48
  </InfoRow>
49
49
  <InfoRow
50
- v-if="taskSnapshot._timeLeftString && taskSnapshot._timeLeftString !== 'No Cartholds'"
50
+ v-if="taskTimeLeft && taskTimeLeft !== 'No Cartholds'"
51
51
  :icon="TimerIcon"
52
52
  label="Cart Expiration"
53
- :value-class="`font-semibold text-right ${taskSnapshot._timeLeftString === '00:00' ? 'text-red-400' : ''}`">
54
- {{ taskSnapshot._timeLeftString !== "00:00" ? taskSnapshot._timeLeftString : "Expired" }}
53
+ :value-class="`font-semibold text-right ${taskTimeLeft === '00:00' ? 'text-red-400' : ''}`">
54
+ {{ taskTimeLeft !== "00:00" ? taskTimeLeft : "Expired" }}
55
55
  </InfoRow>
56
56
  <InfoRow
57
57
  v-if="taskSnapshot.reservedTicketsList"
@@ -192,6 +192,7 @@ import { useCopyToClipboard } from "@/composables/useCopyToClipboard";
192
192
  import { useColorMapping } from "@/composables/useColorMapping";
193
193
  import { useDateFormatting } from "@/composables/useDateFormatting";
194
194
  import { useTicketPricing } from "@/composables/useTicketPricing";
195
+ import { timeDifference } from "@/libs/utils/time";
195
196
  import {
196
197
  EyeIcon,
197
198
  StadiumIcon,
@@ -260,4 +261,17 @@ const toggles = computed(() => ({
260
261
  quickQueue: taskSnapshot.value.quickQueue,
261
262
  agedAccount: taskSnapshot.value.agedAccount
262
263
  }));
264
+
265
+ const taskTimeLeft = computed(() => {
266
+ const now = ui.taskTimeTick || Date.now();
267
+ if (!taskSnapshot.value.expirationTime) return taskSnapshot.value._timeLeftString || "";
268
+ if (taskSnapshot.value.expirationTime === "Invalid Date") return "No Cartholds";
269
+
270
+ const asNumber = Number(taskSnapshot.value.expirationTime);
271
+ const expirationTimestamp = Number.isFinite(asNumber) && asNumber > 0
272
+ ? asNumber
273
+ : Date.parse(taskSnapshot.value.expirationTime);
274
+ if (Number.isNaN(expirationTimestamp)) return taskSnapshot.value._timeLeftString || "00:00";
275
+ return timeDifference(expirationTimestamp, now);
276
+ });
263
277
  </script>