From 5e8f227fbdabbd15fd752645e7a1405803f032d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 17 Jun 2026 14:50:51 +0200 Subject: [PATCH] feat(integration-requests): community board to request & vote bank integrations (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What A community board where users **propose** a bank/service (name + link) and **vote** on the integrations they want most, so we can prioritise by real demand. ## How it works - **Global board**: a shared list sorted by votes desc. `has_voted` and the monthly quota are per user. - **Limit**: 3 combined actions (proposal + vote) per user per calendar month. Creating a proposal **auto-votes** it → costs 2 actions. - **Moderation**: proposals start as `pending` (visible only to their author, with a "Pending review" badge); `approved` ones are visible to everyone. You can't vote on what you can't see (404). The `php artisan integration-requests:review` command approves/rejects pending ones. - **Access**: - Bottom drawer (Vaul, ~95vh) that opens the same board. - Entry points: the **connect-bank** dialog (bank-selector step), the **create-account** dialog, and a global **"Request integration"** item in the user menu (above Support) → available on any screen. - The `/integration-requests` URL renders the **dashboard** with the drawer opened on top (behind auth + verified + onboarded + subscribed). - **Seed**: a migration that inserts Bitunix, XTB, Kraken, Degiro and Interactive Brokers as `approved` and self-voted by the earliest-created user (idempotent, no-op when no users exist). ## Endpoints - `GET /integration-requests` → dashboard + drawer. - `GET /integration-requests/data` → board state as JSON (feeds the drawer). - `POST /integration-requests` → create a proposal (+ auto-vote). - `POST /integration-requests/{id}/vote` → toggle vote. ## Tests - 15 Feature tests (access, status visibility, monthly limit, auto-vote, vote toggle, review command) plus the user-menu tests. All green; `pint`/`eslint`/`prettier`/`tsc`/PHPStan clean. ## Notes / follow-ups - No Pennant flag (ships to everyone). No dedup of duplicate proposals (moderation covers it). - Seeding 5 integrations to one user uses up that user's monthly quota (only affects that account via the UI). --- .../ReviewIntegrationRequestsCommand.php | 71 ++ app/Enums/IntegrationRequestStatus.php | 19 + .../IntegrationRequestController.php | 121 +++ .../StoreIntegrationRequestRequest.php | 25 + app/Models/IntegrationRequest.php | 56 ++ app/Models/IntegrationRequestVote.php | 37 + app/Models/User.php | 12 + .../factories/IntegrationRequestFactory.php | 37 + .../IntegrationRequestVoteFactory.php | 25 + ...4601_create_integration_requests_table.php | 24 + ...create_integration_request_votes_table.php | 25 + ...d_status_to_integration_requests_table.php | 22 + ...2001_seed_initial_integration_requests.php | 52 ++ lang/es.json | 12 + lang/fr.json | 14 +- .../accounts/create-account-dialog.tsx | 7 + .../integration-requests-board.tsx | 259 ++++++ .../integration-requests-drawer.tsx | 35 + resources/js/components/nav-user.tsx | 10 + .../open-banking/connect-account-dialog.tsx | 795 +++++++++--------- .../js/components/user-menu-content.test.tsx | 16 +- resources/js/components/user-menu-content.tsx | 17 +- resources/js/pages/dashboard.tsx | 15 + routes/web.php | 8 + tests/Feature/IntegrationRequestTest.php | 190 +++++ 25 files changed, 1520 insertions(+), 384 deletions(-) create mode 100644 app/Console/Commands/ReviewIntegrationRequestsCommand.php create mode 100644 app/Enums/IntegrationRequestStatus.php create mode 100644 app/Http/Controllers/IntegrationRequestController.php create mode 100644 app/Http/Requests/StoreIntegrationRequestRequest.php create mode 100644 app/Models/IntegrationRequest.php create mode 100644 app/Models/IntegrationRequestVote.php create mode 100644 database/factories/IntegrationRequestFactory.php create mode 100644 database/factories/IntegrationRequestVoteFactory.php create mode 100644 database/migrations/2026_06_17_114601_create_integration_requests_table.php create mode 100644 database/migrations/2026_06_17_114602_create_integration_request_votes_table.php create mode 100644 database/migrations/2026_06_17_120850_add_status_to_integration_requests_table.php create mode 100644 database/migrations/2026_06_17_122001_seed_initial_integration_requests.php create mode 100644 resources/js/components/integration-requests/integration-requests-board.tsx create mode 100644 resources/js/components/integration-requests/integration-requests-drawer.tsx create mode 100644 tests/Feature/IntegrationRequestTest.php diff --git a/app/Console/Commands/ReviewIntegrationRequestsCommand.php b/app/Console/Commands/ReviewIntegrationRequestsCommand.php new file mode 100644 index 00000000..3be25253 --- /dev/null +++ b/app/Console/Commands/ReviewIntegrationRequestsCommand.php @@ -0,0 +1,71 @@ +where('status', IntegrationRequestStatus::Pending) + ->withCount('votes') + ->with('user:id,email') + ->orderBy('created_at') + ->get(); + + if ($pending->isEmpty()) { + $this->info('No pending integration requests.'); + + return self::SUCCESS; + } + + $this->table( + ['Name', 'URL', 'Submitted by', 'Votes', 'Created'], + $pending->map(fn (IntegrationRequest $request): array => [ + $request->name, + $request->url, + $request->user->email, + $request->votes_count, + $request->created_at?->format('Y-m-d') ?? '—', + ])->all(), + ); + + $approved = 0; + $rejected = 0; + + foreach ($pending as $request) { + $decision = $this->choice( + "Review \"{$request->name}\" ({$request->url})", + ['approve', 'reject', 'skip'], + 'skip', + ); + + if ($decision === 'approve') { + $request->update(['status' => IntegrationRequestStatus::Approved]); + $approved++; + } elseif ($decision === 'reject') { + $request->update(['status' => IntegrationRequestStatus::Rejected]); + $rejected++; + } + } + + $skipped = $pending->count() - $approved - $rejected; + $this->info("Done. Approved: {$approved}, rejected: {$rejected}, skipped: {$skipped}."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/IntegrationRequestStatus.php b/app/Enums/IntegrationRequestStatus.php new file mode 100644 index 00000000..6e499eb1 --- /dev/null +++ b/app/Enums/IntegrationRequestStatus.php @@ -0,0 +1,19 @@ + 'Pending', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } +} diff --git a/app/Http/Controllers/IntegrationRequestController.php b/app/Http/Controllers/IntegrationRequestController.php new file mode 100644 index 00000000..acc1c18a --- /dev/null +++ b/app/Http/Controllers/IntegrationRequestController.php @@ -0,0 +1,121 @@ +with('openIntegrationRequests', true); + } + + public function data(Request $request): JsonResponse + { + $user = $request->user(); + + return response()->json([ + 'requests' => $this->list($user), + 'actionsRemaining' => $this->actionsRemaining($user), + ]); + } + + public function store(StoreIntegrationRequestRequest $request): JsonResponse + { + $user = $request->user(); + + // Creating a request also auto-votes it for the author, so it costs two actions. + if ($this->actionsRemaining($user) < 2) { + return $this->limitReachedResponse(); + } + + $integrationRequest = $user->integrationRequests()->create($request->only(['name', 'url'])); + $integrationRequest->votes()->create(['user_id' => $user->id]); + + return $this->payload($user, 201); + } + + public function vote(Request $request, IntegrationRequest $integrationRequest): JsonResponse + { + $user = $request->user(); + + if ($integrationRequest->status !== IntegrationRequestStatus::Approved + && $integrationRequest->user_id !== $user->id) { + abort(404); + } + + $vote = $integrationRequest->votes()->where('user_id', $user->id)->first(); + + if ($vote !== null) { + $vote->delete(); + + return $this->payload($user); + } + + if ($this->actionsRemaining($user) <= 0) { + return $this->limitReachedResponse(); + } + + $integrationRequest->votes()->create(['user_id' => $user->id]); + + return $this->payload($user); + } + + /** + * The board state shared by the page, the drawer and every mutation. + * + * @return Collection + */ + private function list(User $user): Collection + { + return IntegrationRequest::query() + ->where(function ($query) use ($user) { + $query->where('status', IntegrationRequestStatus::Approved) + ->orWhere(function ($inner) use ($user) { + $inner->where('status', IntegrationRequestStatus::Pending) + ->where('user_id', $user->id); + }); + }) + ->withCount('votes') + ->withExists(['votes as has_voted' => fn ($query) => $query->where('user_id', $user->id)]) + ->orderByDesc('votes_count') + ->orderByDesc('created_at') + ->get(); + } + + private function actionsRemaining(User $user): int + { + $start = now()->startOfMonth(); + + $used = $user->integrationRequests()->where('created_at', '>=', $start)->count() + + $user->integrationRequestVotes()->where('created_at', '>=', $start)->count(); + + return max(0, self::MONTHLY_ACTION_LIMIT - $used); + } + + private function payload(User $user, int $status = 200): JsonResponse + { + return response()->json([ + 'requests' => $this->list($user), + 'actionsRemaining' => $this->actionsRemaining($user), + ], $status); + } + + private function limitReachedResponse(): JsonResponse + { + return response()->json([ + 'message' => __('You have reached your monthly limit of :count integration actions. Try again next month.', ['count' => self::MONTHLY_ACTION_LIMIT]), + ], 422); + } +} diff --git a/app/Http/Requests/StoreIntegrationRequestRequest.php b/app/Http/Requests/StoreIntegrationRequestRequest.php new file mode 100644 index 00000000..e342a4b2 --- /dev/null +++ b/app/Http/Requests/StoreIntegrationRequestRequest.php @@ -0,0 +1,25 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'url' => ['required', 'url', 'max:2048'], + ]; + } +} diff --git a/app/Models/IntegrationRequest.php b/app/Models/IntegrationRequest.php new file mode 100644 index 00000000..a88fb64a --- /dev/null +++ b/app/Models/IntegrationRequest.php @@ -0,0 +1,56 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'name', + 'url', + 'status', + 'user_id', + ]; + + /** @var list */ + protected $hidden = [ + 'user_id', + 'updated_at', + ]; + + protected function casts(): array + { + return [ + 'status' => IntegrationRequestStatus::class, + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** @return HasMany */ + public function votes(): HasMany + { + return $this->hasMany(IntegrationRequestVote::class); + } +} diff --git a/app/Models/IntegrationRequestVote.php b/app/Models/IntegrationRequestVote.php new file mode 100644 index 00000000..27a26b89 --- /dev/null +++ b/app/Models/IntegrationRequestVote.php @@ -0,0 +1,37 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'integration_request_id', + 'user_id', + ]; + + /** @return BelongsTo */ + public function integrationRequest(): BelongsTo + { + return $this->belongsTo(IntegrationRequest::class); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 98b9914c..cdc0cf63 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -148,6 +148,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma return $this->hasMany(SuggestionRun::class); } + /** @return HasMany */ + public function integrationRequests(): HasMany + { + return $this->hasMany(IntegrationRequest::class); + } + + /** @return HasMany */ + public function integrationRequestVotes(): HasMany + { + return $this->hasMany(IntegrationRequestVote::class); + } + /** * Whether the user has an active, current-version AI consent. */ diff --git a/database/factories/IntegrationRequestFactory.php b/database/factories/IntegrationRequestFactory.php new file mode 100644 index 00000000..8399bcb5 --- /dev/null +++ b/database/factories/IntegrationRequestFactory.php @@ -0,0 +1,37 @@ + + */ +class IntegrationRequestFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'url' => fake()->url(), + 'status' => IntegrationRequestStatus::Pending, + 'user_id' => User::factory(), + ]; + } + + public function approved(): static + { + return $this->state(['status' => IntegrationRequestStatus::Approved]); + } + + public function rejected(): static + { + return $this->state(['status' => IntegrationRequestStatus::Rejected]); + } +} diff --git a/database/factories/IntegrationRequestVoteFactory.php b/database/factories/IntegrationRequestVoteFactory.php new file mode 100644 index 00000000..5e44112a --- /dev/null +++ b/database/factories/IntegrationRequestVoteFactory.php @@ -0,0 +1,25 @@ + + */ +class IntegrationRequestVoteFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'integration_request_id' => IntegrationRequest::factory(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2026_06_17_114601_create_integration_requests_table.php b/database/migrations/2026_06_17_114601_create_integration_requests_table.php new file mode 100644 index 00000000..ac823f40 --- /dev/null +++ b/database/migrations/2026_06_17_114601_create_integration_requests_table.php @@ -0,0 +1,24 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('url', 2048); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_requests'); + } +}; diff --git a/database/migrations/2026_06_17_114602_create_integration_request_votes_table.php b/database/migrations/2026_06_17_114602_create_integration_request_votes_table.php new file mode 100644 index 00000000..08cea1fe --- /dev/null +++ b/database/migrations/2026_06_17_114602_create_integration_request_votes_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->foreignUuid('integration_request_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['integration_request_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_request_votes'); + } +}; diff --git a/database/migrations/2026_06_17_120850_add_status_to_integration_requests_table.php b/database/migrations/2026_06_17_120850_add_status_to_integration_requests_table.php new file mode 100644 index 00000000..90d0d730 --- /dev/null +++ b/database/migrations/2026_06_17_120850_add_status_to_integration_requests_table.php @@ -0,0 +1,22 @@ +string('status')->default('pending')->index()->after('url'); + }); + } + + public function down(): void + { + Schema::table('integration_requests', function (Blueprint $table) { + $table->dropColumn('status'); + }); + } +}; diff --git a/database/migrations/2026_06_17_122001_seed_initial_integration_requests.php b/database/migrations/2026_06_17_122001_seed_initial_integration_requests.php new file mode 100644 index 00000000..a531e19f --- /dev/null +++ b/database/migrations/2026_06_17_122001_seed_initial_integration_requests.php @@ -0,0 +1,52 @@ + + */ + private array $integrations = [ + ['name' => 'Bitunix', 'url' => 'https://www.bitunix.com/es-es'], + ['name' => 'XTB', 'url' => 'https://www.xtb.com/int'], + ['name' => 'Kraken', 'url' => 'https://www.kraken.com/'], + ['name' => 'Degiro', 'url' => 'https://www.degiro.es/'], + ['name' => 'Interactive Brokers', 'url' => 'https://www.interactivebrokers.com/'], + ]; + + public function up(): void + { + $user = User::query()->oldest('created_at')->first(); + + if ($user === null) { + return; + } + + foreach ($this->integrations as $integration) { + $request = IntegrationRequest::query()->firstOrCreate( + ['name' => $integration['name'], 'user_id' => $user->id], + ['url' => $integration['url'], 'status' => IntegrationRequestStatus::Approved], + ); + + $request->votes()->firstOrCreate(['user_id' => $user->id]); + } + } + + public function down(): void + { + $user = User::query()->oldest('created_at')->first(); + + if ($user === null) { + return; + } + + IntegrationRequest::query() + ->where('user_id', $user->id) + ->whereIn('name', array_column($this->integrations, 'name')) + ->delete(); + } +}; diff --git a/lang/es.json b/lang/es.json index 2b36e816..4f4cf7d7 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1,4 +1,16 @@ { + "Link": "Enlace", + "Submit": "Enviar", + "Pending review": "Pendiente de revisión", + "Something went wrong.": "Algo salió mal.", + "Integration requests": "Solicitudes de integración", + "Request integration": "Solicitar integración", + "Can't find your bank? Request or vote for it": "¿No encuentras tu banco? Solicítalo o vótalo", + "e.g. Revolut": "p. ej. Revolut", + "You have :count actions left this month.": "Te quedan :count acciones este mes.", + "No integrations requested yet. Be the first!": "Aún no hay integraciones solicitadas. ¡Sé el primero!", + "Request a bank or service and vote for the ones you want us to add next.": "Solicita un banco o servicio y vota los que quieres que añadamos a continuación.", + "You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.", "Categorized by AI": "Categorizado por IA", "Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza", "Only show AI guesses": "Mostrar solo sugerencias de IA", diff --git a/lang/fr.json b/lang/fr.json index 833dc375..57088241 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1,4 +1,16 @@ { + "Link": "Lien", + "Submit": "Envoyer", + "Pending review": "En attente de validation", + "Something went wrong.": "Une erreur s'est produite.", + "Integration requests": "Demandes d'intégration", + "Request integration": "Demander une intégration", + "Can't find your bank? Request or vote for it": "Vous ne trouvez pas votre banque ? Demandez-la ou votez", + "e.g. Revolut": "p. ex. Revolut", + "You have :count actions left this month.": "Il vous reste :count actions ce mois-ci.", + "No integrations requested yet. Be the first!": "Aucune intégration demandée pour l'instant. Soyez le premier !", + "Request a bank or service and vote for the ones you want us to add next.": "Demandez une banque ou un service et votez pour ceux que vous voulez que nous ajoutions ensuite.", + "You have reached your monthly limit of :count integration actions. Try again next month.": "Vous avez atteint votre limite mensuelle de :count actions d'intégration. Réessayez le mois prochain.", "Analyze": "Analyser", "Monthly average": "Moyenne mensuelle", "Trend": "S'orienter", @@ -2092,4 +2104,4 @@ "All untracked expenses": "Toutes les dépenses non suivies", "Automatically track every expense that no other budget covers. You can only have one.": "Suit automatiquement chaque dépense qu'aucun autre budget ne couvre. Vous ne pouvez en avoir qu'un seul.", "This catch-all budget tracks every expense that no other budget covers.": "Ce budget général suit chaque dépense qu'aucun autre budget ne couvre." -} \ No newline at end of file +} diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index b5460aad..5c9b5246 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -1,5 +1,6 @@ import { store } from '@/actions/App/Http/Controllers/Settings/AccountController'; import { store as storeBank } from '@/actions/App/Http/Controllers/Settings/BankController'; +import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer'; import { ConnectAccountDialog } from '@/components/open-banking/connect-account-dialog'; import { UpgradeConnectionDialog } from '@/components/open-banking/upgrade-connection-dialog'; import { Button } from '@/components/ui/button'; @@ -51,6 +52,7 @@ export function CreateAccountDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const [connectDialogOpen, setConnectDialogOpen] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false); const formDataRef = useRef({ displayName: '', bankId: null, @@ -337,6 +339,11 @@ export function CreateAccountDialog({ open={upgradeDialogOpen} onOpenChange={setUpgradeDialogOpen} /> + + ); } diff --git a/resources/js/components/integration-requests/integration-requests-board.tsx b/resources/js/components/integration-requests/integration-requests-board.tsx new file mode 100644 index 00000000..796d36be --- /dev/null +++ b/resources/js/components/integration-requests/integration-requests-board.tsx @@ -0,0 +1,259 @@ +import { + data, + store, + vote, +} from '@/actions/App/Http/Controllers/IntegrationRequestController'; +import InputError from '@/components/input-error'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { getCsrfToken } from '@/lib/csrf'; +import { __ } from '@/utils/i18n'; +import { ChevronUp } from 'lucide-react'; +import { FormEvent, useEffect, useState } from 'react'; + +export interface IntegrationRequestItem { + id: string; + name: string; + url: string; + status: 'pending' | 'approved' | 'rejected'; + votes_count: number; + has_voted: boolean; + created_at: string; +} + +interface BoardPayload { + requests: IntegrationRequestItem[]; + actionsRemaining: number; +} + +interface Props { + initialRequests?: IntegrationRequestItem[]; + initialActionsRemaining?: number; +} + +async function sendJson( + url: string, + method: 'GET' | 'POST', + body?: Record, +): Promise { + return fetch(url, { + method, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': getCsrfToken(), + }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +export function IntegrationRequestsBoard({ + initialRequests, + initialActionsRemaining, +}: Props) { + const [requests, setRequests] = useState( + initialRequests ?? null, + ); + const [actionsRemaining, setActionsRemaining] = useState( + initialActionsRemaining ?? 0, + ); + const [name, setName] = useState(''); + const [url, setUrl] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [showForm, setShowForm] = useState(false); + + useEffect(() => { + if (initialRequests !== undefined) { + return; + } + + void (async () => { + const response = await sendJson(data().url, 'GET'); + if (response.ok) { + const payload: BoardPayload = await response.json(); + setRequests(payload.requests); + setActionsRemaining(payload.actionsRemaining); + } + })(); + }, [initialRequests]); + + const apply = (payload: BoardPayload) => { + setRequests(payload.requests); + setActionsRemaining(payload.actionsRemaining); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + setBusy(true); + + try { + const response = await sendJson(store().url, 'POST', { name, url }); + + if (response.ok) { + apply(await response.json()); + setName(''); + setUrl(''); + setShowForm(false); + return; + } + + const body = await response.json(); + setError(body.message ?? __('Something went wrong.')); + } finally { + setBusy(false); + } + }; + + const handleVote = async (item: IntegrationRequestItem) => { + if (busy || (!item.has_voted && actionsRemaining <= 0)) { + return; + } + + setError(null); + setBusy(true); + + try { + const response = await sendJson(vote(item.id).url, 'POST'); + + if (response.ok) { + apply(await response.json()); + return; + } + + const body = await response.json(); + setError(body.message ?? __('Something went wrong.')); + } finally { + setBusy(false); + } + }; + + const outOfActions = actionsRemaining <= 0; + // A new request also auto-votes it, so it costs two actions. + const cannotRequest = actionsRemaining < 2; + + return ( +
+
+

+ {__('You have :count actions left this month.', { + count: actionsRemaining, + })} +

+ {!showForm && ( + + )} +
+ + {showForm && ( +
+
+
+ + setName(e.target.value)} + placeholder={__('e.g. Revolut')} + maxLength={255} + required + /> +
+
+ + setUrl(e.target.value)} + placeholder="https://..." + maxLength={2048} + required + /> +
+
+
+ + +
+ + + )} + + {requests === null ? ( +
+ +
+ ) : requests.length === 0 ? ( +

+ {__('No integrations requested yet. Be the first!')} +

+ ) : ( +
    + {requests.map((item) => ( +
  • +
    + + {item.name} + + {item.status === 'pending' && ( + + {__('Pending review')} + + )} +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/resources/js/components/integration-requests/integration-requests-drawer.tsx b/resources/js/components/integration-requests/integration-requests-drawer.tsx new file mode 100644 index 00000000..3cadebe2 --- /dev/null +++ b/resources/js/components/integration-requests/integration-requests-drawer.tsx @@ -0,0 +1,35 @@ +import { IntegrationRequestsBoard } from '@/components/integration-requests/integration-requests-board'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from '@/components/ui/drawer'; +import { __ } from '@/utils/i18n'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function IntegrationRequestsDrawer({ open, onOpenChange }: Props) { + return ( + + +
+ + {__('Integration requests')} + + {__( + 'Request a bank or service and vote for the ones you want us to add next.', + )} + + + + +
+
+
+ ); +} diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/nav-user.tsx index a6e8538d..20a1da31 100644 --- a/resources/js/components/nav-user.tsx +++ b/resources/js/components/nav-user.tsx @@ -1,3 +1,4 @@ +import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer'; import { SupportDialog } from '@/components/support-dialog'; import { DropdownMenu, @@ -23,6 +24,8 @@ export function NavUser({ className }: { className?: string }) { const { state } = useSidebar(); const isMobile = useIsMobile(); const [supportOpen, setSupportOpen] = useState(false); + const [integrationRequestsOpen, setIntegrationRequestsOpen] = + useState(false); return ( @@ -52,6 +55,9 @@ export function NavUser({ className }: { className?: string }) { setSupportOpen(true)} + onOpenIntegrationRequests={() => + setIntegrationRequestsOpen(true) + } /> @@ -60,6 +66,10 @@ export function NavUser({ className }: { className?: string }) { onOpenChange={setSupportOpen} user={auth.user} /> + ); diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index 2483345e..82ad32f5 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -1,4 +1,5 @@ import { BankLogo } from '@/components/bank-logo'; +import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -95,6 +96,7 @@ export function ConnectAccountDialog({ connections = [], }: ConnectAccountDialogProps) { const [step, setStep] = useState('country'); + const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false); const [country, setCountry] = useState(''); const [institutions, setInstitutions] = useState< EnableBankingInstitution[] @@ -311,421 +313,454 @@ export function ConnectAccountDialog({ } return ( - - - - {__('Connect Bank Account')} - - {step === 'country' && - __( - 'Select the country where your bank is located.', - )} - {step === 'bank' && __('Select your bank.')} - {step === 'confirm' && - isWise && - __( - 'Enter your Wise Personal API token to connect your account.', - )} - {step === 'confirm' && - !isIndexaCapital && - !isBinance && - !isBitpanda && - !isCoinbase && - !isWise && - __( - 'You will be redirected to your bank to authorize access.', - )} - {step === 'confirm' && - isIndexaCapital && - __( - 'Enter your API token to connect your Indexa Capital account.', - )} - {step === 'confirm' && - isBinance && - __( - 'Enter your API Key and Secret to connect your Binance account.', - )} - {step === 'confirm' && - isBitpanda && - __( - 'Enter your API Key to connect your Bitpanda account.', - )} - {step === 'confirm' && - isCoinbase && - __( - 'Enter your CDP App Key ID and Secret to connect your Coinbase account.', - )} - - + <> + + + + {__('Connect Bank Account')} + + {step === 'country' && + __( + 'Select the country where your bank is located.', + )} + {step === 'bank' && __('Select your bank.')} + {step === 'confirm' && + isWise && + __( + 'Enter your Wise Personal API token to connect your account.', + )} + {step === 'confirm' && + !isIndexaCapital && + !isBinance && + !isBitpanda && + !isCoinbase && + !isWise && + __( + 'You will be redirected to your bank to authorize access.', + )} + {step === 'confirm' && + isIndexaCapital && + __( + 'Enter your API token to connect your Indexa Capital account.', + )} + {step === 'confirm' && + isBinance && + __( + 'Enter your API Key and Secret to connect your Binance account.', + )} + {step === 'confirm' && + isBitpanda && + __( + 'Enter your API Key to connect your Bitpanda account.', + )} + {step === 'confirm' && + isCoinbase && + __( + 'Enter your CDP App Key ID and Secret to connect your Coinbase account.', + )} + + - {error &&

{error}

} + {error && ( +

{error}

+ )} - {step === 'country' && ( -
-
- - -
- -
- - -
-
- )} - - {step === 'bank' && ( -
- setSearchQuery(e.target.value)} - /> - -
- {filteredInstitutions.map((institution) => ( - + +
+
+ )} + + {step === 'bank' && ( +
+ setSearchQuery(e.target.value)} + /> + +
+ {filteredInstitutions.map((institution) => ( + + ))} + {filteredInstitutions.length === 0 && ( +

+ {__('No banks found.')} +

+ )} +
+ + + +
+ + +
+
+ )} + + {step === 'confirm' && selectedBank && ( +
+
+
- {institution.name} - - ))} - {filteredInstitutions.length === 0 && ( -

- {__('No banks found.')} -

- )} -
- -
- - -
-
- )} - - {step === 'confirm' && selectedBank && ( -
-
-
- -
-

- {selectedBank.name} -

-

- {isBitpanda - ? __( - 'Connect your Bitpanda account using your API Key.', - ) - : isBinance - ? __( - 'Connect your Binance account using your API Key and Secret.', - ) - : isIndexaCapital +

+

+ {selectedBank.name} +

+

+ {isBitpanda ? __( - 'Connect your Indexa Capital account using your API token.', + 'Connect your Bitpanda account using your API Key.', ) - : isCoinbase + : isBinance ? __( - 'Connect your Coinbase account using a CDP API key.', + 'Connect your Binance account using your API Key and Secret.', ) - : isWise + : isIndexaCapital ? __( - 'Connect your Wise account using a Personal API token.', + 'Connect your Indexa Capital account using your API token.', ) - : __( - 'You will be redirected to authorize access to your account data.', - )} -

+ : isCoinbase + ? __( + 'Connect your Coinbase account using a CDP API key.', + ) + : isWise + ? __( + 'Connect your Wise account using a Personal API token.', + ) + : __( + 'You will be redirected to authorize access to your account data.', + )} +

+
-
- {isIndexaCapital && ( -
- - - setApiToken(e.target.value) - } - placeholder={__( - 'Paste your Indexa Capital API token', - )} - className="my-2" - /> -

- {__( - 'You can generate your API token from your Indexa Capital dashboard under', - )}{' '} - - {__('Settings > Applications')} - - . -

-
- )} - - {isBinance && ( -
+ {isIndexaCapital && (
-
+ )} + + {isBinance && ( +
+
+ + + setApiKey(e.target.value) + } + className="mt-1" + placeholder={__( + 'Paste your Binance API Key', + )} + /> +
+
+ + + setApiSecret(e.target.value) + } + className="mt-1" + placeholder={__( + 'Paste your Binance API Secret', + )} + /> +
+

+ {__( + 'You can create API keys from your Binance account under', + )}{' '} + + {__('API Management')} + + . +

+
+ )} + + {isBitpanda && ( +
+ - setApiKey(e.target.value) + setBitpandaApiKey(e.target.value) } className="mt-1" placeholder={__( - 'Paste your Binance API Key', + 'Paste your Bitpanda API Key', )} /> -
-
- - - setApiSecret(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Binance API Secret', - )} - /> -
-

- {__( - 'You can create API keys from your Binance account under', - )}{' '} - - {__('API Management')} - - . -

-
- )} - - {isBitpanda && ( -
- - - setBitpandaApiKey(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Bitpanda API Key', - )} - /> -

- {__( - 'You can create API keys from your Bitpanda account under', - )}{' '} - - {__('API Key Management')} - - . -

-
- )} - - {isWise && ( -
- - - setWiseApiToken(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Wise API token', - )} - /> -

- {__('Generate a token in Wise under')}{' '} - +

{__( - 'Settings → Developer Tools → API tokens', - )} - - . -

-
- )} + 'You can create API keys from your Bitpanda account under', + )}{' '} + + {__('API Key Management')} + + . +

+
+ )} - {isCoinbase && ( -
+ {isWise && (
-
-
- -