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');