From ee6de2370991ed80f4c6bc38875eb689628a320f Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 29 Apr 2026 09:29:29 +0100 Subject: [PATCH] feat: gate full prediction by ai_predictions feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Controllers/Api/PredictionController.php | 12 ++ resources/js/components/PredictionCard.vue | 128 +++++++++++------- resources/js/views/Home.vue | 36 +++-- .../Feature/Api/PredictionControllerTest.php | 72 +++++++++- 4 files changed, 175 insertions(+), 73 deletions(-) diff --git a/app/Http/Controllers/Api/PredictionController.php b/app/Http/Controllers/Api/PredictionController.php index c9a8596..a9715ab 100644 --- a/app/Http/Controllers/Api/PredictionController.php +++ b/app/Http/Controllers/Api/PredictionController.php @@ -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); } } diff --git a/resources/js/components/PredictionCard.vue b/resources/js/components/PredictionCard.vue index 706a8d7..bf04b0d 100644 --- a/resources/js/components/PredictionCard.vue +++ b/resources/js/components/PredictionCard.vue @@ -1,68 +1,62 @@ @@ -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') diff --git a/resources/js/views/Home.vue b/resources/js/views/Home.vue index d312cad..de3fdce 100644 --- a/resources/js/views/Home.vue +++ b/resources/js/views/Home.vue @@ -36,6 +36,13 @@
+ + +
-
+
@@ -253,23 +260,6 @@ {{ ctaLabel('plus') }}
- - -
-
-

Pro

-
- {{ PRICES[cadence].pro }} - {{ PRICE_SUFFIX[cadence] }} -
-
-
    -
  • AI Price Predictions
  • -
  • Multi-Vehicle Fleet
  • -
  • Exportable Price History
  • -
- {{ ctaLabel('pro') }} -
@@ -316,13 +306,12 @@ -
+

Ready to outsmart the pumps?

Sign up for free today and never pay over the odds for fuel again.

@@ -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) { diff --git a/tests/Feature/Api/PredictionControllerTest.php b/tests/Feature/Api/PredictionControllerTest.php index 77a89d2..9bb57ac 100644 --- a/tests/Feature/Api/PredictionControllerTest.php +++ b/tests/Feature/Api/PredictionControllerTest.php @@ -1,17 +1,44 @@ 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');