Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions iznik-nuxt3/api/MessageAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ export default class MessageAPI extends BaseAPI {
)
}

// Posts semantically similar to a given post, for the "more like this nearby"
// recommendation strip. Returns [{id, groupid, score, lat, lng}].
similar(id, limit) {
return this.$getv2('/message/' + id + '/similar', limit ? { limit } : {})
}

// Existing OFFERs near a location matching a free-text query, for the
// "people are offering these near you" panel shown while composing a WANTED.
// Returns [{id, groupid, score, lat, lng}].
matches(query, lat, lng, limit) {
return this.$getv2('/message/matches', {
query,
lat,
lng,
...(limit ? { limit } : {}),
})
}

mygroups(gid) {
return this.$getv2('/message/mygroups' + (gid ? '/' + gid : ''))
}
Expand Down Expand Up @@ -291,14 +309,14 @@ export default class MessageAPI extends BaseAPI {
return await this.$getv2('/message/count', params, log)
}

async markSeen(ids) {
return await this.$postv2(
'/messages/markseen',
{
ids,
},
false
)
async markSeen(ids, source) {
const body = { ids }
// Optional impression source tag (e.g. 'similar_posts') so a recommendation
// widget's impressions can be measured. Omitted for ordinary browse views.
if (source) {
body.source = source
}
return await this.$postv2('/messages/markseen', body, false)
}

// --- Bulk-offer ("clearance") logged-out update link ---------------------
Expand All @@ -321,9 +339,12 @@ export default class MessageAPI extends BaseAPI {

// Update one item's availability and/or count from the logged-out page.
async updateBulkEditItem(token, itemid, changes) {
return await this.$postv2('/bulkoffer/update/' + encodeURIComponent(token), {
itemid,
...changes,
})
return await this.$postv2(
'/bulkoffer/update/' + encodeURIComponent(token),
{
itemid,
...changes,
}
)
}
}
18 changes: 18 additions & 0 deletions iznik-nuxt3/api/RecommendationsAPI.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import BaseAPI from '@/api/BaseAPI'

export default class RecommendationsAPI extends BaseAPI {
/**
* Fetch the recommendation funnel (impressions → clicks → attributed replies)
* per source plus the holdout comparison, for the sysadmin "Recommendations"
* tab. Support/Admin only.
* Returns { sources: [{ source, impressions, clicks, ctr, attributedReplies,
* daily: [...] }], holdout: {...} }.
* @param {number} days - window size in days (default 30 server-side)
*/
async fetchStats(days) {
return await this.$getv2(
'/modtools/recommendations/stats',
days ? { days } : {}
)
}
}
2 changes: 2 additions & 0 deletions iznik-nuxt3/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import ModConfigsAPI from './ModConfigsAPI.js'
import NewsAPI from './NewsAPI.js'
import NoticeboardAPI from './NoticeboardAPI.js'
import NotificationAPI from './NotificationAPI.js'
import RecommendationsAPI from './RecommendationsAPI.js'
import RipplingAPI from './RipplingAPI.js'
import TownAPI from './TownAPI.js'
import SessionAPI from './SessionAPI.js'
Expand Down Expand Up @@ -93,6 +94,7 @@ export default (config) => {
news: new NewsAPI(options),
noticeboard: new NoticeboardAPI(options),
notification: new NotificationAPI(options),
recommendations: new RecommendationsAPI(options),
rippling: new RipplingAPI(options),
town: new TownAPI(options),
session: new SessionAPI(options),
Expand Down
127 changes: 127 additions & 0 deletions iznik-nuxt3/components/SimilarPosts.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<template>
<!-- The whole strip is lazy: nothing is fetched until it scrolls into view,
and it renders nothing at all (no heading, no reserved space) unless we
have at least a few genuine matches. -->
<section
v-observe-visibility="{
callback: onVisible,
options: { rootMargin: '200px' },
}"
class="similar-posts"
>
<template v-if="show">
<h3 class="similar-posts__heading">More like this nearby</h3>
<div class="similar-posts__row">
<div v-for="mid in ids" :key="mid" class="similar-posts__item">
<MessageSummary :id="mid" @expand="open(mid)" />
</div>
</div>
</template>
</section>
</template>

<script setup>
import { ref, computed } from 'vue'
import { navigateTo } from '#imports'
import { useMessageStore } from '~/stores/message'
import { useMe } from '~/composables/useMe'
import MessageSummary from '~/components/MessageSummary'

const props = defineProps({
// The post we're finding matches for.
msgid: { type: [Number, String], required: true },
})

const MIN_RESULTS = 3
const SOURCE = 'similar_posts'

const messageStore = useMessageStore()
const { myid } = useMe()

const ids = ref([])
// The observe-visibility directive has no "once" option and re-fires on every
// intersection change, so guard against re-entry / repeated loads ourselves.
let loaded = false

// 10% deterministic holdout: logged-in users whose id ends in 0 never see the
// strip (and it never fetches), giving a clean control group for measuring
// whether the recommendations are worth showing. Anonymous users always see it
// and are excluded from the cohort analysis.
const inHoldout = computed(() => !!myid.value && myid.value % 10 === 0)

const show = computed(() => ids.value.length >= MIN_RESULTS)

async function onVisible(visible) {
if (!visible || inHoldout.value || loaded) {
return
}
loaded = true

let results
try {
results = await messageStore.similar(props.msgid, 8)
} catch (e) {
return
}
if (!results || results.length < MIN_RESULTS) {
return
}

const found = results.map((r) => r.id)
// Hydrate the summaries so MessageSummary can render them from the store.
await Promise.all(found.map((id) => messageStore.fetch(id).catch(() => null)))
// Only keep the ones that actually resolved to a renderable message.
ids.value = found.filter((id) => messageStore.list[id])

if (ids.value.length >= MIN_RESULTS) {
// Count the impression once (source-tagged so it's attributable). Fire and
// forget, but swallow rejections so they never surface as unhandled.
messageStore.markSeen(ids.value, SOURCE).catch(() => {})
} else {
ids.value = []
}
}

function open(mid) {
navigateTo('/message/' + mid + '?src=' + SOURCE)
}
</script>

<style scoped lang="scss">
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/_breakpoints';
@import 'assets/css/_color-vars.scss';

.similar-posts {
margin-top: 1.5rem;
}

.similar-posts__heading {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
}

/* Horizontal scroller. The row scrolls inside its own container so it never
pushes the page into horizontal scroll; a partial next card peeks to signal
there's more. */
.similar-posts__row {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.5rem;
}

.similar-posts__item {
flex: 0 0 auto;
width: 15rem;
scroll-snap-align: start;

@include media-breakpoint-down(sm) {
width: 72vw;
}
}
</style>
146 changes: 146 additions & 0 deletions iznik-nuxt3/components/WantedMatches.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<template>
<!-- Renders only when there's at least one matching offer. This panel never
gates the compose flow: it emits nothing that blocks navigation, and its
links open in a new tab so the draft is never lost. -->
<section v-if="!dismissed && ids.length" class="wanted-matches">
<div class="wanted-matches__head">
<h3 class="wanted-matches__heading">
Good news - people are offering these near you right now
</h3>
<b-button
variant="link"
size="sm"
class="wanted-matches__dismiss"
@click="dismissed = true"
>
Not what I'm looking for
</b-button>
</div>
<div class="wanted-matches__row">
<div v-for="mid in ids" :key="mid" class="wanted-matches__item">
<MessageSummary :id="mid" @expand="openInNewTab(mid)" />
</div>
</div>
</section>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useMessageStore } from '~/stores/message'
import MessageSummary from '~/components/MessageSummary'

const props = defineProps({
// Item text of the wanted being composed.
query: { type: String, default: '' },
// The poster's chosen location.
lat: { type: Number, default: 0 },
lng: { type: Number, default: 0 },
})

const SOURCE = 'wanted_match'

const messageStore = useMessageStore()

const ids = ref([])
const dismissed = ref(false)
// Monotonic token so a slow response from a superseded query/location can't
// overwrite the results of a newer one (out-of-order response guard).
let loadToken = 0

async function load() {
const token = ++loadToken
const q = (props.query || '').trim()
if (!q || (!props.lat && !props.lng)) {
ids.value = []
return
}

let results
try {
results = await messageStore.matches(q, props.lat, props.lng, 6)
} catch (e) {
if (token === loadToken) ids.value = []
return
}
if (token !== loadToken) {
return // a newer load() started while we awaited — discard this stale result
}
if (!results || !results.length) {
ids.value = []
return
}

const found = results.map((r) => r.id)
await Promise.all(found.map((id) => messageStore.fetch(id).catch(() => null)))
if (token !== loadToken) {
return
}
ids.value = found.filter((id) => messageStore.list[id])

if (ids.value.length) {
// Fire and forget, but swallow rejections so they never surface as unhandled.
messageStore.markSeen(ids.value, SOURCE).catch(() => {})
}
}

// Re-run whenever the item text or chosen location changes (e.g. the poster
// edits their postcode). Fires immediately for the initial load.
watch(() => [props.query, props.lat, props.lng], load, { immediate: true })

function openInNewTab(mid) {
// Open in a new tab so the in-progress wanted draft in this tab is preserved.
window.open('/message/' + mid + '?src=' + SOURCE, '_blank', 'noopener')
}
</script>

<style scoped lang="scss">
@import 'bootstrap/scss/functions';
@import 'bootstrap/scss/variables';
@import 'bootstrap/scss/mixins/_breakpoints';
@import 'assets/css/_color-vars.scss';

.wanted-matches {
margin: 1rem 0;
padding: 0.75rem;
border: 1px solid $color-gray--light;
border-radius: var(--radius-md, 0.375rem);
background: $color-white;
}

.wanted-matches__head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}

.wanted-matches__heading {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
}

.wanted-matches__dismiss {
padding: 0;
}

.wanted-matches__row {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
padding: 0.5rem 0 0;
}

.wanted-matches__item {
flex: 0 0 auto;
width: 15rem;
scroll-snap-align: start;

@include media-breakpoint-down(sm) {
width: 72vw;
}
}
</style>
Loading