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

Freegle / iznik-nuxt3 / 8385f564-c077-465d-ba8d-bf6d365d8ee8

13 Jan 2026 11:02PM UTC coverage: 43.678% (+0.9%) from 42.782%
8385f564-c077-465d-ba8d-bf6d365d8ee8

push

circleci

actions-user
Auto-merge production to app-ci-fd (daily scheduled)

Automated merge from production branch after successful tests.

🤖 Automated by GitHub Actions

3587 of 8322 branches covered (43.1%)

Branch coverage included in aggregate %.

1 of 1 new or added line in 1 file covered. (100.0%)

248 existing lines in 18 files now uncovered.

1895 of 4229 relevant lines covered (44.81%)

86.5 hits per line

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

75.86
/components/MessageReplySection.vue
1
<template>
2
  <div>
3
    <div v-if="!fromme" class="grey p-2">
64!
4
      <EmailValidator
5
        v-if="!me"
64✔
6
        ref="emailValidatorRef"
7
        v-model:email="stateMachine.email.value"
45✔
8
        v-model:valid="stateMachine.emailValid.value"
45✔
9
        size="lg"
10
        label="Your email address:"
11
        class="test-email-reply-validator"
12
      />
13
      <NoticeMessage
14
        v-if="milesaway > faraway && message.type === 'Offer'"
128!
15
        variant="danger"
16
        class="mt-2 mb-1"
17
      >
18
        This item is {{ milesaway }} miles away. Before replying, are you sure
19
        you can collect from there?
20
      </NoticeMessage>
161!
21
      <NoticeMessage v-if="me?.deleted" variant="danger" class="mt-2 mb-1">
22
        You can't reply until you've decided whether to restore your account.
23
      </NoticeMessage>
×
24
      <div v-else>
25
        <VeeForm ref="form">
26
          <b-form-group
27
            class="flex-grow-1"
28
            label="Your reply:"
29
            :label-for="'replytomessage-' + message.id"
30
            :description="
31
              message.type === 'Offer'
32
                ? 'Explain why you\'d like it.  It\'s not always first come first served.  If appropriate, ask if it\'s working. Always be polite.'
33
                : 'Can you help?  If you have what they\'re looking for, let them know.'
34
            "
35
          >
36
            <div class="d-flex flex-wrap">
37
              <div v-if="message.deliverypossible" class="mb-2 mr-2">
246✔
38
                <b-badge
39
                  v-b-tooltip="
40
                    'They have said they may be able to deliver.  No guarantees - it needs to be convenient for them - but you can ask.'
41
                  "
42
                  variant="info"
43
                  ><v-icon icon="info-circle" /> Delivery may be
195✔
44
                  possible</b-badge
45
                >
46
              </div>
47
              <MessageDeadline :id="id" class="mb-2" />
48
            </div>
49
            <Field
50
              v-if="message.type == 'Offer'"
246!
51
              :id="'replytomessage-' + message.id"
52
              v-model="stateMachine.replyText.value"
267✔
53
              name="reply"
54
              :rules="validateReply"
55
              :validate-on-mount="false"
56
              :validate-on-model-update="false"
57
              as="textarea"
58
              rows="3"
59
              max-rows="8"
60
              class="border border-success w-100"
61
              @input="stateMachine.startTyping"
62
            />
63
            <Field
64
              v-if="message.type == 'Wanted'"
246!
65
              :id="'replytomessage-' + message.id"
66
              v-model="stateMachine.replyText.value"
×
67
              name="reply"
68
              :rules="validateReply"
69
              :validate-on-mount="false"
70
              :validate-on-model-update="false"
71
              as="textarea"
72
              rows="3"
73
              max-rows="8"
74
              class="flex-grow-1 w-100"
75
              @input="stateMachine.startTyping"
76
            />
77
          </b-form-group>
78
          <ErrorMessage name="reply" class="text-danger font-weight-bold" />
79
          <b-form-group
80
            v-if="message.type == 'Offer'"
229!
81
            class="mt-1"
82
            label="When could you collect?"
83
            :label-for="'replytomessage2-' + message.id"
84
            description="Suggest days and times you could collect if you're chosen.  Your plans might change but this speeds up making arrangements."
85
          >
86
            <Field
87
              :id="'replytomessage2-' + message.id"
88
              v-model="stateMachine.collectText.value"
262✔
89
              name="collect"
90
              :rules="validateCollect"
91
              :validate-on-mount="false"
92
              :validate-on-model-update="false"
93
              class="border border-success w-100"
94
              as="textarea"
95
              rows="2"
96
              max-rows="2"
97
            />
98
          </b-form-group>
99
          <ErrorMessage name="collect" class="text-danger font-weight-bold" />
100
        </VeeForm>
101
        <p v-if="me && !alreadyAMember" class="text--small text-muted">
64✔
102
          You're not yet a member of this community; we'll join you. Change
103
          emails or leave communities from
104
          <em>Settings</em>.
105
        </p>
106
      </div>
107
      <div v-if="!me">
64✔
108
        <NewFreegler class="mt-2" />
109
      </div>
110
      <NoticeMessage
111
        v-if="stateMachine.error.value"
64!
112
        variant="danger"
113
        class="mt-2"
114
      >
115
        {{ stateMachine.error.value }}
116
        <b-button
117
          variant="link"
118
          size="sm"
119
          class="p-0 ml-2"
120
          @click="stateMachine.retry"
121
        >
122
          Try again
123
        </b-button>
85!
124
      </NoticeMessage>
125
    </div>
126
    <hr />
127
    <div class="d-flex justify-content-between">
128
      <div class="pr-2 w-50">
129
        <b-button variant="secondary" size="lg" block @click="close">
130
          Cancel
131
        </b-button>
234!
132
      </div>
133
      <div
134
        v-if="!fromme && !me?.deleted"
97✔
135
        class="pl-2 w-50 justify-content-end d-flex"
136
      >
137
        <SpinButton
138
          variant="primary"
139
          size="lg"
140
          done-icon=""
141
          icon-name="angle-double-right"
142
          :disabled="
143
            !stateMachine.canSend.value || stateMachine.isProcessing.value
144
          "
145
          iconlast
146
          @handle="handleSend"
147
        >
148
          Send <span class="d-none d-md-inline">your</span> reply
58✔
149
        </SpinButton>
150
      </div>
151
    </div>
152
    <b-modal
153
      v-if="stateMachine.showWelcomeModal.value"
64✔
154
      id="newUserModal"
155
      ref="newUserModal"
156
      scrollable
157
      ok-only
158
      ok-title="Close and Continue"
159
      @ok="handleNewUserModalOk"
160
    >
161
      <template #title>
162
        <h2>Welcome to Freegle!</h2>
163
      </template>
164
      <NewUserInfo :password="stateMachine.newUserPassword.value" />
165
    </b-modal>
166
    <span ref="breakpoint" class="d-inline d-sm-none" />
167
    <div class="d-none">
168
      <ChatButton ref="replyToPostChatButton" :userid="replyToUser" />
169
    </div>
170
  </div>
171
</template>
172
<script setup>
173
import { Form as VeeForm, Field, ErrorMessage } from 'vee-validate'
174
import {
175
  defineAsyncComponent,
176
  ref,
177
  computed,
178
  watch,
179
  nextTick,
180
  onMounted,
181
} from 'vue'
182
import { useRoute } from 'vue-router'
183
import { useMessageStore } from '~/stores/message'
184
import { milesAway } from '~/composables/useDistance'
185
import { useMe } from '~/composables/useMe'
186
import {
187
  useReplyStateMachine,
188
  ReplyState,
189
} from '~/composables/useReplyStateMachine'
190
import { action } from '~/composables/useClientLog'
191
import EmailValidator from '~/components/EmailValidator'
192
import NewUserInfo from '~/components/NewUserInfo'
193
import ChatButton from '~/components/ChatButton'
194
import SpinButton from '~/components/SpinButton.vue'
195
import NoticeMessage from '~/components/NoticeMessage'
196
import MessageDeadline from '~/components/MessageDeadline'
197
import { FAR_AWAY } from '~/constants'
198

199
const route = useRoute()
21✔
200

201
const NewFreegler = defineAsyncComponent(() =>
202
  import('~/components/NewFreegler')
203
)
204

205
const props = defineProps({
206
  id: {
207
    type: Number,
208
    required: true,
209
  },
210
  messageOverride: {
211
    type: Object,
212
    required: false,
213
    default: null,
214
  },
215
})
216

217
const emit = defineEmits(['close', 'sent'])
218

219
const faraway = FAR_AWAY
220

221
const messageStore = useMessageStore()
222
const { me, myid, myGroups } = useMe()
223

224
// Initialize state machine
225
const stateMachine = useReplyStateMachine(props.id)
226

227
// References
228
const form = ref(null)
229
const newUserModal = ref(null)
230
const replyToPostChatButton = ref(null)
231
const breakpoint = ref(null)
232
const emailValidatorRef = ref(null)
233

234
// Fetch the message data
235
await messageStore.fetch(props.id)
21✔
236

237
const message = computed(() => {
21✔
238
  return messageStore?.byId(props.id)
32!
239
})
240

241
const milesaway = computed(() => {
242
  return milesAway(me?.lat, me?.lng, message.value?.lat, message.value?.lng)
32!
243
})
244

245
const fromme = computed(() => {
246
  return message.value?.fromuser === myid.value
39!
247
})
248

249
const alreadyAMember = computed(() => {
250
  let found = false
35✔
251

252
  if (message.value?.groups) {
35✔
253
    for (const messageGroup of message.value.groups) {
32✔
254
      Object.keys(myGroups.value).forEach((key) => {
32✔
255
        const group = myGroups.value[key]
18✔
256

257
        if (messageGroup.groupid === group.id) {
18✔
258
          found = true
259
        }
260
      })
261
    }
262
  }
263

264
  return found
35✔
265
})
266

267
const replyToUser = computed(() => {
268
  return message.value?.fromuser
32!
269
})
270

271
// Watch for login state changes to resume authentication flow
272
watch(me, (newVal, oldVal) => {
21✔
273
  console.log('[MessageReplySection] Login state changed', {
17✔
274
    newVal: !!newVal,
275
    oldVal: !!oldVal,
276
  })
277
  if (
278
    !oldVal &&
36✔
279
    newVal &&
280
    stateMachine.state.value === ReplyState.AUTHENTICATING
281
  ) {
282
    console.log(
283
      '[MessageReplySection] User logged in during authentication - resuming'
284
    )
285
    stateMachine.onLoginSuccess()
286
  }
287
})
288

289
// Watch for chat button ref becoming available
290
watch(replyToPostChatButton, (newVal) => {
291
  if (newVal) {
21✔
292
    console.log('[MessageReplySection] Chat button ref now available')
293
    stateMachine.setRefs({ chatButton: newVal })
294
  }
295
})
296

297
// Watch for form ref
298
watch(form, (newVal) => {
299
  if (newVal) {
21✔
300
    console.log('[MessageReplySection] Form ref now available')
301
    stateMachine.setRefs({ form: newVal })
302
  }
303
})
304

305
// Determine reply source from route for analytics.
306
function getReplySource() {
21✔
307
  const path = route.path
21✔
308
  const query = route.query || {}
21!
309

310
  // Check for email digest/newsletter links.
311
  if (query.src === 'digest' || query.utm_source === 'digest') {
21!
312
    return 'email_digest'
313
  }
314
  if (query.src === 'newsletter' || query.utm_source === 'newsletter') {
21!
315
    return 'email_newsletter'
316
  }
317
  if (query.src || query.utm_source) {
21!
318
    return `email_${query.src || query.utm_source}`
×
319
  }
320

321
  // Determine from route path.
322
  if (path.startsWith('/browse')) {
21!
323
    return 'browse_page'
324
  }
325
  if (path.startsWith('/explore')) {
21✔
326
    return 'explore_page'
327
  }
328
  if (path.match(/^\/message\/\d+/)) {
17!
329
    return 'message_page'
330
  }
331
  if (path.startsWith('/find')) {
×
332
    return 'find_page'
333
  }
334

335
  return 'unknown'
336
}
337

338
// Set refs on mount
339
onMounted(() => {
21✔
340
  console.log('[MessageReplySection] Component mounted, setting refs')
21✔
341
  stateMachine.setRefs({
342
    form: form.value,
343
    chatButton: replyToPostChatButton.value,
344
    emailValidator: emailValidatorRef.value,
345
  })
346

347
  // Set and log the reply source for analytics.
348
  const replySource = getReplySource()
21✔
349
  stateMachine.setReplySource(replySource)
21✔
350
  action('reply_section_viewed', {
351
    message_id: props.id,
352
    reply_source: replySource,
353
    message_type: message.value?.type,
21!
354
    is_logged_in: !!me.value,
355
    route_path: route.path,
356
    route_query: JSON.stringify(route.query || {}),
21!
357
    // Additional diagnostics to verify render completed.
358
    has_message_data: !!message.value,
359
    from_me: fromme.value,
360
    has_form_ref: !!form.value,
361
    has_chat_button_ref: !!replyToPostChatButton.value,
362
    can_send: stateMachine.canSend.value,
363
    state_machine_state: stateMachine.state.value,
364
  })
365
})
366

367
// Watch for state machine completion
368
watch(
369
  () => stateMachine.isComplete.value,
370
  (isComplete) => {
371
    if (isComplete) {
7✔
372
      console.log('[MessageReplySection] Reply flow completed')
373
      sent()
374
    }
375
  }
376
)
377

378
// Watch for welcome modal state to show it
379
watch(
380
  () => stateMachine.showWelcomeModal.value,
381
  async (showModal) => {
2✔
382
    if (showModal) {
2✔
383
      console.log('[MessageReplySection] Showing welcome modal')
384
      await nextTick()
4✔
385
      newUserModal.value?.show()
386
    }
387
  }
388
)
389

390
function validateCollect(value) {
69✔
391
  if (value && value.trim()) {
69✔
392
    return true
393
  }
394
  return 'Please suggest some days and times when you could collect.'
395
}
396

397
function validateReply(value) {
78✔
398
  if (!value?.trim()) {
78!
399
    return 'Please fill out your reply.'
400
  }
401

402
  if (
403
    message.value?.type === 'Offer' &&
404
    value &&
405
    value.length <= 35 &&
406
    value.toLowerCase().includes('still available')
407
  ) {
408
    return (
409
      "You don't need to ask if things are still available. Just write whatever you " +
410
      "would have said next - explain why you'd like it and when you could collect."
411
    )
412
  }
413

414
  return true
415
}
416

417
async function handleSend(callback) {
12✔
418
  console.log('[MessageReplySection] handleSend called')
12✔
419

420
  // Log that the Send button was clicked with state info for debugging.
421
  action('reply_send_clicked', {
422
    message_id: props.id,
423
    has_form_ref: !!form.value,
424
    has_chat_button_ref: !!replyToPostChatButton.value,
425
    has_email_validator_ref: !!emailValidatorRef.value,
426
    is_logged_in: !!me.value,
427
    state_machine_state: stateMachine.state.value,
428
    can_send: stateMachine.canSend.value,
429
    is_processing: stateMachine.isProcessing.value,
430
    has_reply_text: !!stateMachine.replyText.value?.trim(),
24✔
431
    has_collect_text: !!stateMachine.collectText.value?.trim(),
24✔
432
  })
433

434
  // Ensure refs are set before submitting
435
  stateMachine.setRefs({
436
    form: form.value,
437
    chatButton: replyToPostChatButton.value,
438
    emailValidator: emailValidatorRef.value,
439
  })
440
  await stateMachine.submit(callback)
441
}
442

UNCOV
443
function handleNewUserModalOk() {
×
UNCOV
444
  console.log('[MessageReplySection] New user modal closed')
×
445
  stateMachine.closeWelcomeModal()
446
}
447

UNCOV
448
function close() {
×
UNCOV
449
  emit('close')
×
450
}
451

452
function sent() {
7✔
453
  emit('sent')
7✔
454
}
455
</script>
456
<style scoped lang="scss">
457
.grey {
458
  background-color: $color-gray--lighter;
459
}
460

461
:deep(.phone) {
462
  border: 2px solid $color-gray--normal !important;
463
}
464

465
.nobot {
466
  margin-bottom: 0 !important;
467
}
468
</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