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 && (
-
-
- -