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

Freegle / iznik-nuxt3 / ac1d9782-b35f-4f16-9565-e960836a0784

09 Apr 2026 09:36PM UTC coverage: 45.817% (+0.1%) from 45.716%
ac1d9782-b35f-4f16-9565-e960836a0784

push

circleci

CircleCI Auto-merge
Auto-merge master to production after successful tests - Original commit: Add donation modal funnel instrumentation via clientlog

4582 of 10195 branches covered (44.94%)

Branch coverage included in aggregate %.

2 of 6 new or added lines in 2 files covered. (33.33%)

82 existing lines in 8 files now uncovered.

2029 of 4234 relevant lines covered (47.92%)

63.46 hits per line

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

42.36
/components/MyPostsPostsList.vue
1
<template>
2
  <div class="my-posts-list">
381✔
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">
175✔
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"
175✔
12
        class="toolbar-toggle"
13
        @click="toggleShowOldPosts"
14
      >
15
        <v-icon :icon="showOldPosts ? 'eye-slash' : 'eye'" class="me-1" />
3✔
16
        <span class="count-full"
17
          >{{ showOldPosts ? 'Hide' : 'Show' }}
3✔
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 = ''">
175!
31
          <v-icon icon="times" />
984!
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">
381✔
80
      <Spinner :size="30" />
81
    </div>
82

83
    <!-- Posts list -->
84
    <div v-else-if="visiblePosts.length > 0" class="posts-container">
222✔
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
        :key="infiniteKey"
101
        :distance="scrollboxHeight"
102
        @infinite="(event) => emit('load-more', event)"
250✔
103
      />
104
    </div>
105

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

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

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

157
const emit = defineEmits(['load-more', 'toggle-old'])
158

159
const scrollboxHeight = ref(1000)
160

161
const showOldPosts = ref(false)
162
const filterText = ref('')
163
const infiniteKey = ref(0)
164

165
function toggleShowOldPosts() {
1✔
166
  showOldPosts.value = !showOldPosts.value
1✔
167
  infiniteKey.value++
168
  emit('toggle-old')
169
}
170

171
// Posts are now passed directly as props
172
const posts = computed(() => {
80✔
173
  return props.posts || []
259!
174
})
175

176
// old posts are those with an outcome
177
const oldPosts = computed(() => {
178
  return posts.value.filter((post) => post.hasoutcome)
179
})
180

181
const formattedOldPostsCount = computed(() => {
182
  return pluralize(`old post`, oldPosts.value.length, true)
183
})
184

185
const formattedActivePostsCount = computed(() => {
186
  return pluralize(`active post`, activePosts.value.length, true)
187
})
188

189
const activePosts = computed(() => {
190
  return posts.value.filter((post) => !post.hasoutcome)
191
})
192

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

208
const visiblePosts = computed(() => {
80✔
209
  let visiblePostList = showOldPosts.value ? posts.value : activePosts.value
182✔
210
  visiblePostList = visiblePostList || []
182!
211

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

223
  const sorted = visiblePostList.toSorted((a, b) => {
182✔
224
    /* promised items first, then by most recently */
225
    if (!showOldPosts.value && a.promised && !b.promised) {
72!
226
      return -1
227
    } else if (!showOldPosts.value && b.promised && !a.promised) {
24!
228
      return 1
229
    } else {
230
      return new Date(b.arrival).getTime() - new Date(a.arrival).getTime()
231
    }
232
  })
233

234
  /* Active posts are few — show all immediately.
235
     Only paginate when showing old posts (can be hundreds). */
236
  return showOldPosts.value ? sorted.slice(0, props.show) : sorted
182✔
237
})
238

239
const upcomingTrysts = computed(() => {
240
  const ret = []
186✔
241

242
  activePosts.value.forEach((post) => {
186✔
243
    const message = messageStore.byId(post.id)
183✔
244
    if (post.type === 'Offer' && message?.promises?.length) {
183!
245
      message.promises.forEach((p) => {
246
        const user = userStore?.byId(p.userid)
×
247
        const isSomeone = p.userid === myid.value
248

249
        if (isSomeone || user) {
×
UNCOV
250
          const tryst = trystStore?.getByUser(p.userid)
×
251

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

UNCOV
262
            ret.push({
×
263
              id: p.userid,
264
              name: isSomeone ? 'Someone' : user.displayname,
×
265
              tryst,
266
              trystdate: date,
267
              subject: message.subject,
268
            })
269
          }
270
        }
271
      })
272
    }
273
  })
274

275
  return ret.toSorted((a, b) => {
276
    return (
277
      new Date(a.tryst.arrangedfor).getTime() -
278
      new Date(b.tryst.arrangedfor).getTime()
279
    )
280
  })
281
})
282

283
const COLLECTIONS_INITIAL = 3
284
const showAllCollections = ref(false)
285

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

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

318
.my-posts-list {
319
  padding: 0;
320
}
321

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

335
@keyframes fadeIn {
336
  to {
337
    opacity: 1;
338
  }
339
}
340

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

352
.toolbar-count {
353
  color: $color-success;
354
  font-weight: 500;
355
  font-size: 0.85rem;
356
  white-space: nowrap;
357
  display: inline-flex;
358
  align-items: center;
359
}
360

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

373
  &:hover {
374
    background: $color-gray--lighter;
375
    border-color: $color-gray--base;
376
  }
377
}
378

379
/* On wider screens show full text, hide short */
380
.count-short {
381
  display: none;
382
}
383

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

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

415
.search-icon {
416
  color: $color-gray--base;
417
  font-size: 0.8rem;
418
  flex-shrink: 0;
419
}
420

421
.search-input {
422
  border: none;
423
  background: none;
424
  outline: none;
425
  font-size: 0.85rem;
426
  width: 100%;
427
  color: $color-gray--darker;
428

429
  &::placeholder {
430
    color: $color-gray--base;
431
  }
432
}
433

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

444
  &:hover {
445
    color: $color-gray--normal;
446
  }
447
}
448

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

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

467
.collection-group {
468
  margin-bottom: 8px;
469

470
  &:last-child {
471
    margin-bottom: 0;
472
  }
473
}
474

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

485
.collection-item {
486
  padding: 2px 0 2px 22px;
487
  font-size: 0.9rem;
488
  line-height: 1.4;
489
  border-bottom: none;
490
}
491

492
.collection-who {
493
  color: var(--color-gray-600);
494
}
495

496
.collections-more {
497
  margin-top: 8px;
498
  padding-top: 4px;
499
  border-top: 1px solid $color-gray--lighter;
500
}
501

502
.collection-icon {
503
  color: $color-blue--bright;
504
  font-size: 1rem;
505
  margin-top: 2px;
506
}
507

508
.posts-container {
509
  display: flex;
510
  flex-direction: column;
511
  gap: 0;
512
}
513

514
.loading-placeholder {
515
  background: white;
516
  margin-bottom: 12px;
517
  box-shadow: var(--shadow-sm);
518
}
519

520
.loading-placeholder::before {
521
  content: '';
522
  display: block;
523
  width: 100%;
524
  padding-bottom: 50%;
525
  background: $color-gray--light;
526
}
527

528
.loading-more {
529
  display: flex;
530
  justify-content: center;
531
  padding: 20px;
532
}
533

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

547
.empty-icon {
548
  font-size: 3rem;
549
  color: var(--color-gray-400);
550
  margin-bottom: 1rem;
551
}
552

553
.empty-text {
554
  font-size: 1.1rem;
555
  color: var(--color-gray-600);
556
  margin-bottom: 1.25rem;
557
}
558

559
.empty-actions {
560
  display: flex;
561
  gap: 12px;
562
  flex-wrap: wrap;
563
  justify-content: center;
564
}
565

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

576
  &:active {
577
    transform: scale(0.98);
578
  }
579

580
  &--give {
581
    background: $color-success;
582
    color: $color-white;
583

584
    &:hover {
585
      background: darken($color-success, 5%);
586
      color: $color-white;
587
    }
588
  }
589

590
  &--find {
591
    background: $color-secondary;
592
    color: $color-white;
593

594
    &:hover {
595
      background: darken($color-secondary, 5%);
596
      color: $color-white;
597
    }
598
  }
599
}
600

601
.minheight {
602
  min-height: 200px;
603
}
604
</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