feat: gate full prediction by ai_predictions feature flag
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>
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\PredictionRequest;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PredictionController extends Controller
|
||||
@@ -20,6 +21,17 @@ class PredictionController extends Controller
|
||||
|
||||
$result = $this->predictionService->predict($lat, $lng);
|
||||
|
||||
$user = $request->user();
|
||||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||
|
||||
if (! $canSeeFull) {
|
||||
return response()->json([
|
||||
'fuel_type' => $result['fuel_type'],
|
||||
'predicted_direction' => $result['predicted_direction'],
|
||||
'tier_locked' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,62 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Gated overlay for free/guest users -->
|
||||
<div>
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="!isPaidTier"
|
||||
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
|
||||
v-if="loading"
|
||||
class="p-6 bg-white rounded-2xl border border-zinc-300 animate-pulse space-y-2"
|
||||
>
|
||||
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon>
|
||||
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p>
|
||||
<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"
|
||||
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
|
||||
>
|
||||
Upgrade from £0.99/mo
|
||||
See full prediction →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card content (blurred for free users, fully visible for paid) -->
|
||||
<!-- Paid: full prediction -->
|
||||
<div
|
||||
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
|
||||
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>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="loading">
|
||||
<div class="animate-pulse space-y-2">
|
||||
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
|
||||
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
|
||||
</div>
|
||||
</template>
|
||||
<h3
|
||||
:class="['text-2xl font-black', actionTextColor]"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</h3>
|
||||
|
||||
<!-- Loaded state -->
|
||||
<template v-else-if="prediction">
|
||||
<h3
|
||||
class="text-2xl font-black"
|
||||
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
|
||||
:style="{ width: prediction.confidence_score + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
|
||||
|
||||
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
|
||||
|
||||
<div class="flex items-center gap-4 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>
|
||||
</template>
|
||||
|
||||
<!-- Empty state (placeholder for gated view) -->
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
|
||||
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
|
||||
<p class="text-sm text-zinc-500">Prices in your area are rising — best to fill up today.</p>
|
||||
</template>
|
||||
<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>
|
||||
@@ -84,4 +78,40 @@ const actionLabel = computed(() => {
|
||||
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>
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
<!-- Prediction box (sits above filter results) -->
|
||||
<PredictionCard
|
||||
:is-paid-tier="showFullPrediction"
|
||||
:loading="predictionLoading"
|
||||
:prediction="prediction"
|
||||
/>
|
||||
|
||||
<!-- Post-search filter bar -->
|
||||
<PostSearchFilters
|
||||
v-model:brand-filter="brandFilter"
|
||||
@@ -201,7 +208,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<!-- Free -->
|
||||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||
<div class="mb-8">
|
||||
@@ -253,23 +260,6 @@
|
||||
</ul>
|
||||
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">{{ PRICES[cadence].pro }}</span>
|
||||
<span class="text-zinc-400 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
|
||||
</ul>
|
||||
<a :href="ctaHref('pro')" class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('pro') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -316,13 +306,12 @@
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center">
|
||||
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated">
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
|
||||
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
|
||||
<a class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all" href="#">Watch Demo Video</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -394,7 +383,9 @@ import { useAuth } from '../composables/useAuth.js'
|
||||
import { useStations } from '../composables/useStations.js'
|
||||
import api from '../axios.js'
|
||||
import PostSearchFilters from '../components/PostSearchFilters.vue'
|
||||
import PredictionCard from '../components/PredictionCard.vue'
|
||||
import StationList from '../components/StationList.vue'
|
||||
import { usePrediction } from '../composables/usePrediction.js'
|
||||
|
||||
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
|
||||
import LandingNav from '../components/landing/LandingNav.vue'
|
||||
@@ -404,6 +395,8 @@ import HeroSearch from '../components/landing/HeroSearch.vue'
|
||||
import StatsRow from '../components/landing/StatsRow.vue'
|
||||
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
const { prediction, loading: predictionLoading, fetch: fetchPrediction } = usePrediction()
|
||||
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
|
||||
|
||||
const liveStats = ref({ stationCount: null, latestPriceAt: null })
|
||||
|
||||
@@ -564,6 +557,9 @@ async function runSearch(params) {
|
||||
radiusMiles.value = params.radius ?? radiusMiles.value
|
||||
searchAttempted.value = true
|
||||
await search(params)
|
||||
const lat = meta.value?.lat ?? params.lat ?? null
|
||||
const lng = meta.value?.lng ?? params.lng ?? null
|
||||
fetchPrediction(lat && lng ? { lat, lng } : {})
|
||||
}
|
||||
|
||||
async function onSearch(params) {
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
});
|
||||
|
||||
it('returns a prediction response', function () {
|
||||
function actAsTier(string $tier): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
if ($tier !== 'free') {
|
||||
UserResource::applyTier($user, $tier, 'monthly');
|
||||
}
|
||||
|
||||
test()->actingAs($user->fresh());
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('returns the full payload for plus users', function () {
|
||||
actAsTier('plus');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['fuel_type', 'reasoning', 'signals'])
|
||||
->assertJsonMissingPath('tier_locked');
|
||||
});
|
||||
|
||||
it('returns the full payload for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
@@ -31,7 +58,40 @@ it('returns a prediction response', function () {
|
||||
->assertJsonPath('region_key', 'national');
|
||||
});
|
||||
|
||||
it('includes current average from live prices', function () {
|
||||
it('returns only direction + tier_locked flag for guests', function () {
|
||||
$response = $this->getJson('/api/prediction')->assertOk();
|
||||
|
||||
expect($response->json())
|
||||
->toHaveKey('fuel_type')
|
||||
->toHaveKey('predicted_direction')
|
||||
->toHaveKey('tier_locked', true)
|
||||
->not->toHaveKey('current_avg')
|
||||
->not->toHaveKey('reasoning')
|
||||
->not->toHaveKey('signals');
|
||||
});
|
||||
|
||||
it('returns the trimmed payload for free users', function () {
|
||||
actAsTier('free');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('tier_locked', true)
|
||||
->assertJsonMissing(['signals' => []])
|
||||
->assertJsonMissingPath('reasoning');
|
||||
});
|
||||
|
||||
it('returns the trimmed payload for basic users', function () {
|
||||
actAsTier('basic');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('tier_locked', true)
|
||||
->assertJsonMissingPath('reasoning');
|
||||
});
|
||||
|
||||
it('includes current average from live prices for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$station = Station::factory()->create();
|
||||
StationPriceCurrent::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
@@ -44,14 +104,18 @@ it('includes current average from live prices', function () {
|
||||
expect($response->json('current_avg'))->toBe(147.5);
|
||||
});
|
||||
|
||||
it('returns regional prediction when lat and lng are provided', function () {
|
||||
it('returns regional prediction when lat and lng are provided to pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$this->getJson('/api/prediction?lat=52.5&lng=-0.2')
|
||||
->assertOk()
|
||||
->assertJsonPath('region_key', 'regional')
|
||||
->assertJsonPath('fuel_type', 'e10');
|
||||
});
|
||||
|
||||
it('returns national prediction without coordinates', function () {
|
||||
it('returns national prediction without coordinates for pro users', function () {
|
||||
actAsTier('pro');
|
||||
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('region_key', 'national');
|
||||
|
||||
Reference in New Issue
Block a user