• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-nuxt3 / 9f339386-b298-4f12-85aa-a0ce1c5a46b4

02 Apr 2026 09:34PM UTC coverage: 44.782% (-0.3%) from 45.054%
9f339386-b298-4f12-85aa-a0ce1c5a46b4

push

circleci

CircleCI Auto-merge
Auto-merge master to production after successful tests - Original commit: test(ModMergeMemberModal): add unit tests for merge by id and email

4499 of 10299 branches covered (43.68%)

Branch coverage included in aggregate %.

2028 of 4276 relevant lines covered (47.43%)

58.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

35.42
/components/MyPostsPostsList.vue
1
<template>
2
  <div class="my-posts-list">
347✔
3
    <!-- Single toolbar row: counts + old-posts toggle + search -->
4
    <div v-if="!loading && posts.length > 0" class="posts-toolbar">
5
      <span v-if="activePosts.length > 0" class="toolbar-count">
153!
6
        <v-icon icon="gift" class="me-1" />
7
        <span class="count-full">{{ formattedActivePostsCount }}</span>
8
        <span class="count-short">{{ activePosts.length }} active</span>
9
      </span>
10
      <button
11
        v-if="oldPosts.length > 0"
153!
12
        class="toolbar-toggle"
13
        @click="toggleShowOldPosts"
14
      >
15
        <v-icon :icon="showOldPosts ? 'eye-slash' : 'eye'" class="me-1" />
×
16
        <span class="count-full"
17
          >{{ showOldPosts ? 'Hide' : 'Show' }}
×
18
          {{ formattedOldPostsCount }}</span
19
        >
20
        <span class="count-short">{{ oldPosts.length }} old</span>
21
      </button>
22
      <div class="toolbar-search">
23
        <v-icon icon="search" class="search-icon" />
24
        <input
25
          v-model="filterText"
26
          type="text"
27
          class="search-input"
28
          placeholder="Filter posts…"
29
        />
30
        <button v-if="filterText" class="search-clear" @click="filterText = ''">
153!
31
          <v-icon icon="times" />
891!
32
        </button>
33
      </div>
34
    </div>
35

36
    <!-- Upcoming collections -->
37
    <div v-if="!loading && upcomingTrysts.length > 0" class="collections-card">
38
      <h3 class="collections-title">
39
        <v-icon icon="calendar-check" class="me-2" />Your upcoming collections
×
40
      </h3>
41
      <div
42
        v-for="group in visibleCollectionGroups"
43
        :key="group.trystdate"
44
        class="collection-group"
45
      >
46
        <div class="collection-time">
47
          <v-icon icon="calendar-alt" class="collection-icon" />
48
          {{ group.trystdate }}
49
        </div>
50
        <div
51
          v-for="item in group.items"
52
          :key="'item-' + item.id"
53
          class="collection-item"
54
        >
55
          <em>{{ item.subject }}</em
56
          ><span class="collection-who"> — {{ item.name }}</span>
57
        </div>
58
      </div>
59
      <div
60
        v-if="upcomingTrysts.length > COLLECTIONS_INITIAL"
×
61
        class="collections-more"
62
      >
63
        <b-button
64
          variant="link"
65
          size="sm"
66
          class="p-0"
67
          @click="showAllCollections = !showAllCollections"
×
68
        >
69
          {{
70
            showAllCollections
×
71
              ? 'Show fewer'
72
              : `Show ${upcomingTrysts.length - COLLECTIONS_INITIAL} more`
73
          }}
74
        </b-button>
75
      </div>
76
    </div>
77

78
    <!-- Loading state -->
79
    <div v-if="loading" class="loading-state">
347✔
80
      <Spinner :size="30" />
81
    </div>
82

83
    <!-- Posts list -->
84
    <div v-else-if="visiblePosts.length > 0" class="posts-container">
197✔
85
      <div v-for="post in visiblePosts" :key="'post-' + post.id">
86
        <Suspense>
87
          <MyMessage
88
            :id="post.id"
89
            :show-old="showOldPosts"
90
            :expand="defaultExpanded"
91
          />
92
          <template #fallback>
93
            <div class="loading-placeholder">
94
              <Spinner :size="40" />
95
            </div>
96
          </template>
97
        </Suspense>
98
      </div>
99
      <InfiniteLoading
100
        :distance="scrollboxHeight"
101
        @infinite="(event) => emit('load-more', event)"
225✔
102
      />
103
    </div>
104

105
    <!-- Empty state -->
106
    <div v-else class="empty-state">
107
      <v-icon icon="folder-open" class="empty-icon" />
108
      <p class="empty-text">You have no active posts.</p>
66✔
109
      <div class="empty-actions">
110
        <template v-if="props.type === 'Offer'">
44!
111
          <nuxt-link to="/give" class="mobile-btn mobile-btn--give">
112
            <v-icon icon="gift" class="me-2" />Give stuff
×
113
          </nuxt-link>
114
        </template>
115
        <template v-else-if="props.type === 'Wanted'">
44!
116
          <nuxt-link to="/find" class="mobile-btn mobile-btn--find">
117
            <v-icon icon="search" class="me-2" />Find stuff
×
118
          </nuxt-link>
119
        </template>
120
        <template v-else>
121
          <nuxt-link to="/give" class="mobile-btn mobile-btn--give">
122
            <v-icon icon="gift" class="me-2" />Give stuff
66✔
123
          </nuxt-link>
124
          <nuxt-link to="/find" class="mobile-btn mobile-btn--find">
125
            <v-icon icon="search" class="me-2" />Find stuff
66✔
126
          </nuxt-link>
127
        </template>
128
      </div>
129
    </div>
130
  </div>
131
</template>
132
<script setup>
133
import pluralize from 'pluralize'
134
import dayjs from 'dayjs'
135
import MyMessage from '~/components/MyMessage.vue'
136
import InfiniteLoading from '~/components/InfiniteLoading.vue'
137
import { useMessageStore } from '~/stores/message'
138
import { useUserStore } from '~/stores/user'
139
import { useTrystStore } from '~/stores/tryst'
140
import { useAuthStore } from '~/stores/auth'
141

142
const messageStore = useMessageStore()
76✔
143
const userStore = useUserStore()
144
const trystStore = useTrystStore()
145
const authStore = useAuthStore()
146
const myid = computed(() => authStore.user?.id)
×
147

148
const props = defineProps({
149
  posts: { type: Array, required: true },
150
  postIds: { type: Array, required: false, default: () => [] },
151
  loading: { type: Boolean, required: true },
152
  defaultExpanded: { type: Boolean, required: true },
153
  show: { type: Number, required: true },
154
})
155

156
const emit = defineEmits(['load-more'])
157

158
const scrollboxHeight = ref(1000)
159

160
const showOldPosts = ref(false)
161
const filterText = ref('')
162

163
function toggleShowOldPosts() {
×
164
  showOldPosts.value = !showOldPosts.value
×
165
}
166

167
// Posts are now passed directly as props
168
const posts = computed(() => {
76✔
169
  return props.posts || []
237!
170
})
171

172
// old posts are those with an outcome
173
const oldPosts = computed(() => {
174
  return posts.value.filter((post) => post.hasoutcome)
175
})
176

177
const formattedOldPostsCount = computed(() => {
178
  return pluralize(`old post`, oldPosts.value.length, true)
179
})
180

181
const formattedActivePostsCount = computed(() => {
182
  return pluralize(`active post`, activePosts.value.length, true)
183
})
184

185
const activePosts = computed(() => {
186
  return posts.value.filter((post) => !post.hasoutcome)
187
})
188

189
watch(activePosts, (newVal) => {
76✔
190
  // For messages which are promised and not successful, we need to trigger a fetch.  This is so
191
  // that we can correctly show the upcoming collections.
192
  newVal.forEach((post) => {
161✔
193
    if (
194
      post.type === 'Offer' &&
159!
195
      post.promised &&
196
      !post.hasoutcome &&
197
      !messageStore.byId(post.id)
198
    ) {
199
      messageStore.fetch(post.id)
200
    }
201
  })
202
})
203

204
const visiblePosts = computed(() => {
76✔
205
  let visiblePostList = showOldPosts.value ? posts.value : activePosts.value
159!
206
  visiblePostList = visiblePostList || []
159!
207

208
  const filter = filterText.value.trim().toLowerCase()
159✔
209
  if (filter) {
159!
210
    visiblePostList = visiblePostList.filter((post) => {
211
      const msg = messageStore.byId(post.id)
×
212
      if (!msg) return true /* not loaded yet — keep visible */
×
213
      const subject = (msg.subject || '').toLowerCase()
×
214
      const body = (msg.textbody || msg.body || '').toLowerCase()
×
215
      return subject.includes(filter) || body.includes(filter)
×
216
    })
217
  }
218

219
  const sorted = visiblePostList.toSorted((a, b) => {
159✔
220
    /* promised items first, then by most recently */
221
    if (!showOldPosts.value && a.promised && !b.promised) {
60!
222
      return -1
223
    } else if (!showOldPosts.value && b.promised && !a.promised) {
20!
224
      return 1
225
    } else {
226
      return new Date(b.arrival).getTime() - new Date(a.arrival).getTime()
227
    }
228
  })
229

230
  /* Active posts are few — show all immediately.
231
     Only paginate when showing old posts (can be hundreds). */
232
  return showOldPosts.value ? sorted.slice(0, props.show) : sorted
159!
233
})
234

235
const upcomingTrysts = computed(() => {
236
  const ret = []
164✔
237

238
  activePosts.value.forEach((post) => {
164✔
239
    const message = messageStore.byId(post.id)
162✔
240
    if (post.type === 'Offer' && message?.promises?.length) {
162!
241
      message.promises.forEach((p) => {
242
        const user = userStore?.byId(p.userid)
×
243
        const isSomeone = p.userid === myid.value
244

245
        if (isSomeone || user) {
×
246
          const tryst = trystStore?.getByUser(p.userid)
×
247

248
          // If tryst.arrangedfor is in the future or within the last hour
249
          if (
×
250
            tryst &&
×
251
            new Date(tryst.arrangedfor).getTime() >
252
              new Date().getTime() - 60 * 60 * 1000
253
          ) {
254
            const date = tryst
×
255
              ? dayjs(tryst.arrangedfor).format('dddd Do HH:mm a')
256
              : null
257

258
            ret.push({
×
259
              id: p.userid,
260
              name: isSomeone ? 'Someone' : user.displayname,
×
261
              tryst,
262
              trystdate: date,
263
              subject: message.subject,
264
            })
265
          }
266
        }
267
      })
268
    }
269
  })
270

271
  return ret.toSorted((a, b) => {
272
    return (
273
      new Date(a.tryst.arrangedfor).getTime() -
274
      new Date(b.tryst.arrangedfor).getTime()
275
    )
276
  })
277
})
278

279
const COLLECTIONS_INITIAL = 3
280
const showAllCollections = ref(false)
281

282
/* Group sorted trysts by time label, then optionally slice to the first
283
   COLLECTIONS_INITIAL items (across all groups) for the collapsed view. */
284
const groupedCollections = computed(() => {
285
  const groups = []
×
286
  const seen = {}
287
  upcomingTrysts.value.forEach((t) => {
×
288
    if (!seen[t.trystdate]) {
×
289
      seen[t.trystdate] = { trystdate: t.trystdate, items: [] }
290
      groups.push(seen[t.trystdate])
291
    }
292
    seen[t.trystdate].items.push(t)
293
  })
294
  return groups
295
})
296

297
const visibleCollectionGroups = computed(() => {
298
  if (showAllCollections.value) return groupedCollections.value
×
299
  /* Slice to the first COLLECTIONS_INITIAL items across all groups */
300
  let remaining = COLLECTIONS_INITIAL
×
301
  const result = []
×
302
  for (const group of groupedCollections.value) {
×
303
    if (remaining <= 0) break
×
304
    const items = group.items.slice(0, remaining)
×
305
    result.push({ trystdate: group.trystdate, items })
×
306
    remaining -= items.length
307
  }
308
  return result
×
309
})
310
</script>
311
<style scoped lang="scss">
312
@import 'assets/css/_color-vars.scss';
313

314
.my-posts-list {
315
  padding: 0;
316
}
317

318
.loading-state {
319
  display: flex;
320
  justify-content: center;
321
  align-items: center;
322
  min-height: 300px;
323
  padding: 40px;
324
  background: white;
325
  box-shadow: var(--shadow-md);
326
  opacity: 0;
327
  animation: fadeIn 0.2s ease-in forwards;
328
  animation-delay: 0.3s;
329
}
330

331
@keyframes fadeIn {
332
  to {
333
    opacity: 1;
334
  }
335
}
336

337
.posts-toolbar {
338
  display: flex;
339
  align-items: center;
340
  gap: 8px;
341
  padding: 8px 12px;
342
  margin-bottom: 12px;
343
  background: white;
344
  border: 1px solid $color-gray--light;
345
  box-shadow: var(--shadow-sm);
346
}
347

348
.toolbar-count {
349
  color: $color-success;
350
  font-weight: 500;
351
  font-size: 0.85rem;
352
  white-space: nowrap;
353
  display: inline-flex;
354
  align-items: center;
355
}
356

357
.toolbar-toggle {
358
  display: inline-flex;
359
  align-items: center;
360
  padding: 4px 10px;
361
  background: none;
362
  border: 1px solid $color-gray--light;
363
  color: var(--color-gray-600);
364
  font-size: 0.85rem;
365
  cursor: pointer;
366
  white-space: nowrap;
367
  border-radius: 4px;
368

369
  &:hover {
370
    background: $color-gray--lighter;
371
    border-color: $color-gray--base;
372
  }
373
}
374

375
/* On wider screens show full text, hide short */
376
.count-short {
377
  display: none;
378
}
379

380
@media (max-width: 480px) {
381
  .count-full {
382
    display: none;
383
  }
384
  .count-short {
385
    display: inline;
386
  }
387
  .posts-toolbar {
388
    padding: 6px 8px;
389
    gap: 6px;
390
  }
391
  .toolbar-search {
392
    min-width: 0;
393
    flex: 1;
394
  }
395
}
396

397
.toolbar-search {
398
  display: flex;
399
  align-items: center;
400
  margin-left: auto;
401
  background: $color-gray--lighter;
402
  border: 1px solid $color-gray--light;
403
  border-radius: 20px;
404
  padding: 4px 10px;
405
  gap: 6px;
406
  flex: 1;
407
  min-width: 0;
408
  max-width: 260px;
409
}
410

411
.search-icon {
412
  color: $color-gray--base;
413
  font-size: 0.8rem;
414
  flex-shrink: 0;
415
}
416

417
.search-input {
418
  border: none;
419
  background: none;
420
  outline: none;
421
  font-size: 0.85rem;
422
  width: 100%;
423
  color: $color-gray--darker;
424

425
  &::placeholder {
426
    color: $color-gray--base;
427
  }
428
}
429

430
.search-clear {
431
  background: none;
432
  border: none;
433
  padding: 0;
434
  color: $color-gray--base;
435
  cursor: pointer;
436
  font-size: 0.75rem;
437
  line-height: 1;
438
  flex-shrink: 0;
439

440
  &:hover {
441
    color: $color-gray--normal;
442
  }
443
}
444

445
.collections-card {
446
  background: white;
447
  border-radius: var(--radius-lg, 0.75rem);
448
  padding: 16px;
449
  margin-bottom: 16px;
450
  box-shadow: var(--shadow-md);
451
  border-left: 4px solid $color-blue--bright;
452
}
453

454
.collections-title {
455
  display: flex;
456
  align-items: center;
457
  font-size: 1rem;
458
  font-weight: 600;
459
  color: $color-gray--darker;
460
  margin: 0 0 12px 0;
461
}
462

463
.collection-group {
464
  margin-bottom: 8px;
465

466
  &:last-child {
467
    margin-bottom: 0;
468
  }
469
}
470

471
.collection-time {
472
  display: flex;
473
  align-items: center;
474
  gap: 6px;
475
  font-weight: 600;
476
  font-size: 0.9rem;
477
  color: $color-gray--darker;
478
  margin-bottom: 4px;
479
}
480

481
.collection-item {
482
  padding: 2px 0 2px 22px;
483
  font-size: 0.9rem;
484
  line-height: 1.4;
485
  border-bottom: none;
486
}
487

488
.collection-who {
489
  color: var(--color-gray-600);
490
}
491

492
.collections-more {
493
  margin-top: 8px;
494
  padding-top: 4px;
495
  border-top: 1px solid $color-gray--lighter;
496
}
497

498
.collection-icon {
499
  color: $color-blue--bright;
500
  font-size: 1rem;
501
  margin-top: 2px;
502
}
503

504
.posts-container {
505
  display: flex;
506
  flex-direction: column;
507
  gap: 0;
508
}
509

510
.loading-placeholder {
511
  background: white;
512
  margin-bottom: 12px;
513
  box-shadow: var(--shadow-sm);
514
}
515

516
.loading-placeholder::before {
517
  content: '';
518
  display: block;
519
  width: 100%;
520
  padding-bottom: 50%;
521
  background: $color-gray--light;
522
}
523

524
.loading-more {
525
  display: flex;
526
  justify-content: center;
527
  padding: 20px;
528
}
529

530
.empty-state {
531
  display: flex;
532
  flex-direction: column;
533
  align-items: center;
534
  justify-content: center;
535
  padding: 3rem 1.5rem;
536
  background: var(--color-gray-50);
537
  border-radius: var(--radius-lg, 0.75rem);
538
  border: 1px dashed var(--color-gray-300);
539
  max-width: 480px;
540
  margin: 1rem auto;
541
}
542

543
.empty-icon {
544
  font-size: 3rem;
545
  color: var(--color-gray-400);
546
  margin-bottom: 1rem;
547
}
548

549
.empty-text {
550
  font-size: 1.1rem;
551
  color: var(--color-gray-600);
552
  margin-bottom: 1.25rem;
553
}
554

555
.empty-actions {
556
  display: flex;
557
  gap: 12px;
558
  flex-wrap: wrap;
559
  justify-content: center;
560
}
561

562
.mobile-btn {
563
  display: flex;
564
  align-items: center;
565
  justify-content: center;
566
  padding: 0.6rem 1.5rem;
567
  font-size: 0.9rem;
568
  font-weight: 600;
569
  text-decoration: none;
570
  transition: transform 0.1s;
571

572
  &:active {
573
    transform: scale(0.98);
574
  }
575

576
  &--give {
577
    background: $color-success;
578
    color: $color-white;
579

580
    &:hover {
581
      background: darken($color-success, 5%);
582
      color: $color-white;
583
    }
584
  }
585

586
  &--find {
587
    background: $color-secondary;
588
    color: $color-white;
589

590
    &:hover {
591
      background: darken($color-secondary, 5%);
592
      color: $color-white;
593
    }
594
  }
595
}
596

597
.minheight {
598
  min-height: 200px;
599
}
600
</style>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc