feat(ai): dismissable AI consent banner that stops after the first decision (#617)
## What The transactions AI consent banner now lets users decline, tells them the outcome, and stops appearing once they've decided. - **Dismiss option**: an X button before "Enable AI" permanently hides the banner without granting consent. - **Show only until the first decision**: the choice is persisted on `users.ai_consent_prompt_dismissed_at` (set on both accept and dismiss). The banner shows only while the user hasn't responded and never reappears afterwards — even if they later revoke consent from settings. - **Clear feedback**: accepting or dismissing shows a toast stating whether AI was enabled and that the choice can be changed anytime in **Settings > Manage Plan**. - **Full-width layout on desktop**: the banner content spans the full available width (button pushed to the right, text on a single line). The narrow stacked layout is kept for mobile only. ## How - New `POST ai/consent/dismiss` endpoint (`AiConsentController::dismiss`). - `User::dismissAiConsentPrompt()` (idempotent) + `hasDismissedAiConsentPrompt()`; `store` now also marks the prompt dismissed on accept. - Migration adds the nullable `ai_consent_prompt_dismissed_at` timestamp. - `TransactionController` passes `aiConsentPromptDismissed` to the page. ## Tests - `AiConsentTest`: idempotent dismissal, dismissal without consent, and accept marking the prompt dismissed. - Full consent + backfill suite and the `create a transaction` browser test pass; pint / lint / format clean.
This commit is contained in:
parent
af64f56399
commit
7e36bbafef
|
|
@ -17,6 +17,7 @@ class AiConsentController extends Controller
|
|||
{
|
||||
$user = $request->user();
|
||||
$user->recordAiConsent();
|
||||
$user->dismissAiConsentPrompt();
|
||||
|
||||
return response()->json([
|
||||
'consented' => true,
|
||||
|
|
@ -24,6 +25,16 @@ class AiConsentController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently dismiss the AI consent prompt without granting consent.
|
||||
*/
|
||||
public function dismiss(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->dismissAiConsentPrompt();
|
||||
|
||||
return response()->json(['dismissed' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the user's AI consent.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ class TransactionController extends Controller
|
|||
'labels' => $labels,
|
||||
'automationRules' => $automationRules,
|
||||
'hasAiConsent' => $user->hasActiveAiConsent(),
|
||||
'aiConsentPromptDismissed' => $user->hasDismissedAiConsentPrompt(),
|
||||
'lastVisitAt' => $lastVisitAt?->toISOString(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ use Laravel\Pennant\Concerns\HasFeatures;
|
|||
* @property ?Carbon $last_logged_in_at
|
||||
* @property ?Carbon $last_active_at
|
||||
* @property ?Carbon $transactions_last_visited_at
|
||||
* @property ?Carbon $ai_consent_prompt_dismissed_at
|
||||
*/
|
||||
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
|
||||
{
|
||||
|
|
@ -79,6 +80,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
'last_logged_in_at' => 'datetime',
|
||||
'last_active_at' => 'datetime',
|
||||
'transactions_last_visited_at' => 'datetime',
|
||||
'ai_consent_prompt_dismissed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +195,25 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
$this->aiConsents()->active($scope)->update(['revoked_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user has already answered the AI consent prompt (accepted or
|
||||
* dismissed it), so the transactions banner should no longer be shown.
|
||||
*/
|
||||
public function hasDismissedAiConsentPrompt(): bool
|
||||
{
|
||||
return $this->ai_consent_prompt_dismissed_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently dismiss the AI consent prompt (idempotent).
|
||||
*/
|
||||
public function dismissAiConsentPrompt(): void
|
||||
{
|
||||
if ($this->ai_consent_prompt_dismissed_at === null) {
|
||||
$this->forceFill(['ai_consent_prompt_dismissed_at' => now()])->save();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return HasMany<Label, $this> */
|
||||
public function labels(): HasMany
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->timestamp('ai_consent_prompt_dismissed_at')->nullable()->after('paywall_seen_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('ai_consent_prompt_dismissed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -22,6 +22,9 @@
|
|||
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Con tu permiso, enviamos los nombres de los comercios de tus transacciones a nuestro proveedor de IA para que pueda sugerir categorías. Puedes revocarlo en cualquier momento.",
|
||||
"AI categorization enabled": "Categorización con IA activada",
|
||||
"AI categorization disabled": "Categorización con IA desactivada",
|
||||
"AI enabled. You can change this anytime in Settings > Manage Plan.": "IA activada. Puedes cambiar esta opción cuando quieras en Configuración > Gestionar Plan.",
|
||||
"AI not enabled. You can turn it on anytime in Settings > Manage Plan.": "IA no activada. Puedes activarla cuando quieras en Configuración > Gestionar Plan.",
|
||||
"Dismiss": "Descartar",
|
||||
"Let AI categorize your transactions": "Deja que la IA categorice tus transacciones",
|
||||
"Give your consent and our AI will suggest categories for your transactions automatically.": "Da tu consentimiento y nuestra IA sugerirá categorías para tus transacciones automáticamente.",
|
||||
"Enable AI": "Activar IA",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
|
||||
import axios from 'axios';
|
||||
import { format, getYear, parseISO } from 'date-fns';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight, X } from 'lucide-react';
|
||||
import {
|
||||
createElement,
|
||||
useCallback,
|
||||
|
|
@ -89,7 +89,10 @@ import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirma
|
|||
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { status as categorizationStatus } from '@/routes/ai/categorization';
|
||||
import { store as storeConsent } from '@/routes/ai/consent';
|
||||
import {
|
||||
dismiss as dismissConsent,
|
||||
store as storeConsent,
|
||||
} from '@/routes/ai/consent';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
|
|
@ -137,6 +140,7 @@ interface Props {
|
|||
labels: Label[];
|
||||
automationRules: AutomationRule[];
|
||||
hasAiConsent: boolean;
|
||||
aiConsentPromptDismissed: boolean;
|
||||
lastVisitAt: string | null;
|
||||
}
|
||||
|
||||
|
|
@ -429,17 +433,19 @@ export default function Transactions({
|
|||
labels: initialLabels,
|
||||
automationRules,
|
||||
hasAiConsent,
|
||||
aiConsentPromptDismissed,
|
||||
lastVisitAt,
|
||||
}: Props) {
|
||||
const locale = useLocale();
|
||||
const { auth, features } = usePage<SharedData>().props;
|
||||
const [aiConsentGiven, setAiConsentGiven] = useState(false);
|
||||
const [aiConsentResolved, setAiConsentResolved] = useState(false);
|
||||
const [aiConsentSaving, setAiConsentSaving] = useState(false);
|
||||
const showAiConsentBanner =
|
||||
auth.hasProPlan &&
|
||||
features.aiConsentSettings &&
|
||||
!hasAiConsent &&
|
||||
!aiConsentGiven;
|
||||
!aiConsentPromptDismissed &&
|
||||
!aiConsentResolved;
|
||||
const [categorizingIds, setCategorizingIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
|
@ -733,7 +739,12 @@ export default function Transactions({
|
|||
const { data } = await axios.post<AiConsentResponse>(
|
||||
storeConsent.url(),
|
||||
);
|
||||
setAiConsentGiven(true);
|
||||
setAiConsentResolved(true);
|
||||
toast.success(
|
||||
__(
|
||||
'AI enabled. You can change this anytime in Settings > Manage Plan.',
|
||||
),
|
||||
);
|
||||
|
||||
if (data.categorization) {
|
||||
// Spin every currently-visible uncategorized row while the
|
||||
|
|
@ -755,8 +766,6 @@ export default function Transactions({
|
|||
data.categorization.job_id,
|
||||
data.categorization.total,
|
||||
);
|
||||
} else {
|
||||
toast.success(__('AI categorization enabled'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(__('Something went wrong.'));
|
||||
|
|
@ -765,6 +774,18 @@ export default function Transactions({
|
|||
}
|
||||
}, [allTransactions, pollCategorizationStatus]);
|
||||
|
||||
const handleDismissAiConsent = useCallback(() => {
|
||||
setAiConsentResolved(true);
|
||||
axios.post(dismissConsent.url()).catch(() => {
|
||||
// Best-effort: the banner is already hidden for this session.
|
||||
});
|
||||
toast(
|
||||
__(
|
||||
'AI not enabled. You can turn it on anytime in Settings > Manage Plan.',
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Load More with cursor pagination (fetch directly to avoid cursor in URL)
|
||||
const { component, version } = usePage();
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
|
|
@ -1428,12 +1449,12 @@ export default function Transactions({
|
|||
)}
|
||||
topRow={
|
||||
showAiConsentBanner ? (
|
||||
<div className="flex flex-col gap-3 bg-gradient-to-r from-violet-50 via-sky-50 to-rose-50 px-4 py-4 sm:flex-row sm:items-center dark:from-violet-950/30 dark:via-sky-950/20 dark:to-rose-950/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col gap-3 bg-gradient-to-r from-violet-50 via-sky-50 to-rose-50 px-4 py-4 sm:flex-row sm:items-center sm:justify-between dark:from-violet-950/30 dark:via-sky-950/20 dark:to-rose-950/20">
|
||||
<div className="flex gap-3 sm:flex-1">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white/80 shadow-sm dark:bg-white/10">
|
||||
<AiSparkleIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="max-w-80">
|
||||
<div className="max-w-80 sm:max-w-none">
|
||||
<p className="font-medium">
|
||||
{__(
|
||||
'Let AI categorize your transactions',
|
||||
|
|
@ -1446,15 +1467,29 @@ export default function Transactions({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleEnableAi}
|
||||
disabled={aiConsentSaving}
|
||||
variant="secondary"
|
||||
className="max-w-[calc(var(--spacing)_*_86)] bg-white px-6!"
|
||||
>
|
||||
{__('Enable AI')}
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={
|
||||
handleDismissAiConsent
|
||||
}
|
||||
disabled={aiConsentSaving}
|
||||
className="opacity-20 hover:opacity-100 transition-all duration-300"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={__('Dismiss')}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEnableAi}
|
||||
disabled={aiConsentSaving}
|
||||
variant="secondary"
|
||||
className="max-w-[calc(var(--spacing)_*_86)] bg-white px-6!"
|
||||
>
|
||||
{__('Enable AI')}
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
|
||||
// AI rule suggestions — accessible during onboarding (auto-apply) and after.
|
||||
Route::post('ai/consent', [AiConsentController::class, 'store'])->name('ai.consent.store');
|
||||
Route::post('ai/consent/dismiss', [AiConsentController::class, 'dismiss'])->name('ai.consent.dismiss');
|
||||
Route::delete('ai/consent', [AiConsentController::class, 'destroy'])->name('ai.consent.destroy');
|
||||
Route::get('ai/categorization/{jobId}/status', [CategorizationController::class, 'status'])->name('ai.categorization.status');
|
||||
Route::prefix('ai/rule-suggestions')->name('ai.rule-suggestions.')->group(function () {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
use App\Models\AiConsent;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
|
||||
it('records a consent and reports it as active', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
@ -41,3 +43,37 @@ it('treats consent from a previous version as inactive', function () {
|
|||
|
||||
expect($user->hasActiveAiConsent())->toBeFalse();
|
||||
});
|
||||
|
||||
it('dismisses the consent prompt idempotently', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->hasDismissedAiConsentPrompt())->toBeFalse();
|
||||
|
||||
$user->dismissAiConsentPrompt();
|
||||
$dismissedAt = $user->ai_consent_prompt_dismissed_at;
|
||||
|
||||
$user->dismissAiConsentPrompt();
|
||||
|
||||
expect($user->hasDismissedAiConsentPrompt())->toBeTrue()
|
||||
->and($user->ai_consent_prompt_dismissed_at->equalTo($dismissedAt))->toBeTrue();
|
||||
});
|
||||
|
||||
it('marks the prompt as dismissed when consent is granted', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
actingAs($user)->postJson(route('ai.consent.store'))->assertOk();
|
||||
|
||||
expect($user->refresh()->hasDismissedAiConsentPrompt())->toBeTrue()
|
||||
->and($user->hasActiveAiConsent())->toBeTrue();
|
||||
});
|
||||
|
||||
it('dismisses the prompt without granting consent', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
actingAs($user)->postJson(route('ai.consent.dismiss'))
|
||||
->assertOk()
|
||||
->assertJson(['dismissed' => true]);
|
||||
|
||||
expect($user->refresh()->hasDismissedAiConsentPrompt())->toBeTrue()
|
||||
->and($user->hasActiveAiConsent())->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue