Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.
Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.
Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.0 KiB
Vue
118 lines
4.0 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Loading state -->
|
|
<div
|
|
v-if="loading"
|
|
class="p-6 bg-white rounded-2xl border border-zinc-300 animate-pulse space-y-2"
|
|
>
|
|
<div class="h-4 bg-zinc-200 rounded w-1/3"></div>
|
|
<div class="h-6 bg-zinc-200 rounded w-2/3"></div>
|
|
</div>
|
|
|
|
<!-- Free / guest: compact one-liner -->
|
|
<div
|
|
v-else-if="!isPaidTier"
|
|
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
|
|
>
|
|
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
|
|
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
|
|
</div>
|
|
<p class="flex-1 text-sm text-zinc-800 font-medium leading-snug">
|
|
{{ genericSentence }}
|
|
</p>
|
|
<a
|
|
class="hidden sm:inline-flex shrink-0 text-sm font-bold text-accent hover:text-accent-content whitespace-nowrap"
|
|
href="/pricing"
|
|
>
|
|
See full prediction →
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Paid: full prediction -->
|
|
<div
|
|
v-else-if="prediction"
|
|
class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-4"
|
|
>
|
|
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
|
|
|
|
<h3
|
|
:class="['text-2xl font-black', actionTextColor]"
|
|
>
|
|
{{ actionLabel }}
|
|
</h3>
|
|
|
|
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
|
|
<div
|
|
:class="['h-full rounded-full transition-all', actionBarColor]"
|
|
:style="{ width: prediction.confidence_score + '%' }"
|
|
></div>
|
|
</div>
|
|
|
|
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
|
|
|
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-zinc-500 font-medium">
|
|
<span>Avg: {{ prediction.current_avg }}p</span>
|
|
<span>Confidence: {{ prediction.confidence_label }}</span>
|
|
<span v-if="prediction.predicted_change_pence">
|
|
{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue'
|
|
|
|
const props = defineProps({
|
|
prediction: { type: Object, default: null },
|
|
loading: { type: Boolean, default: false },
|
|
isPaidTier: { type: Boolean, default: false },
|
|
})
|
|
|
|
const actionLabel = computed(() => {
|
|
if (!props.prediction) return ''
|
|
return {
|
|
fill_now: 'Fill up now',
|
|
wait: 'Wait — prices falling',
|
|
no_signal: 'No clear signal',
|
|
}[props.prediction.action] ?? 'Check local prices'
|
|
})
|
|
|
|
const actionTextColor = computed(() => {
|
|
if (!props.prediction) return 'text-zinc-800'
|
|
return {
|
|
fill_now: 'text-mauve',
|
|
wait: 'text-teal',
|
|
}[props.prediction.action] ?? 'text-tan'
|
|
})
|
|
|
|
const actionBarColor = computed(() => {
|
|
if (!props.prediction) return 'bg-zinc-400'
|
|
return {
|
|
fill_now: 'bg-mauve',
|
|
wait: 'bg-teal',
|
|
}[props.prediction.action] ?? 'bg-tan'
|
|
})
|
|
|
|
const direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
|
|
|
|
const genericSentence = computed(() => ({
|
|
up: 'UK fuel prices are trending upward this week.',
|
|
down: 'UK fuel prices have been falling this week.',
|
|
stable: 'UK fuel prices have been steady this week.',
|
|
})[direction.value] ?? 'UK fuel prices have been steady this week.')
|
|
|
|
const genericIcon = computed(() => ({
|
|
up: 'lucide:trending-up',
|
|
down: 'lucide:trending-down',
|
|
stable: 'lucide:minus',
|
|
})[direction.value] ?? 'lucide:minus')
|
|
|
|
const accentBg = computed(() => ({
|
|
up: 'bg-mauve',
|
|
down: 'bg-teal',
|
|
stable: 'bg-tan',
|
|
})[direction.value] ?? 'bg-tan')
|
|
</script>
|