Skip to content

Commit 72d0ff2

Browse files
committed
Refine Ledger gateway management UI
1 parent d5e5c2f commit 72d0ff2

55 files changed

Lines changed: 1676 additions & 494 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

addon/components/gateway/details.hbs

Lines changed: 242 additions & 124 deletions
Large diffs are not rendered by default.

addon/components/gateway/details.js

Lines changed: 344 additions & 13 deletions
Large diffs are not rendered by default.

addon/components/gateway/form.hbs

Lines changed: 300 additions & 169 deletions
Large diffs are not rendered by default.

addon/components/gateway/form.js

Lines changed: 196 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,144 @@ import { tracked } from '@glimmer/tracking';
33
import { inject as service } from '@ember/service';
44
import { action } from '@ember/object';
55
import { task } from 'ember-concurrency';
6+
import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
67

78
export default class GatewayFormComponent extends Component {
89
@service fetch;
910
@service notifications;
1011

1112
@tracked availableDrivers = [];
13+
@tracked activeStep = 0;
1214
@tracked configSchema = [];
1315
@tracked configValues = {};
16+
@tracked connectionTestResult;
1417

1518
constructor() {
1619
super(...arguments);
1720
this.loadDrivers.perform();
1821
}
1922

23+
get setupSteps() {
24+
const steps = [
25+
{ icon: 'key', label: 'Credentials', complete: this.hasRequiredConfig },
26+
{ icon: 'route', label: 'Routing', complete: Boolean(this.args.resource?.environment && this.args.resource?.status) },
27+
{ icon: 'circle-check', label: 'Review', complete: Boolean(this.args.resource?.name && this.args.resource?.driver) },
28+
];
29+
30+
if (this.canSelectDriver) {
31+
steps.unshift({ icon: 'plug', label: 'Gateway', complete: Boolean(this.selectedDriver) });
32+
}
33+
34+
return steps.map((step, index) => ({
35+
...step,
36+
key: step.label.toLowerCase(),
37+
active: this.activeStep === index,
38+
complete: index < this.activeStep && step.complete,
39+
}));
40+
}
41+
42+
get currentStep() {
43+
return this.setupSteps[this.activeStep]?.key ?? this.setupSteps[0]?.key;
44+
}
45+
46+
get canSelectDriver() {
47+
return !this.args.resource?.id;
48+
}
49+
50+
get driverCards() {
51+
return this.availableDrivers.map((driver) => ({
52+
...driver,
53+
selected: driver.code === this.args.resource?.driver,
54+
categoryLabel: driver.category ?? this.driverCategory(driver.code),
55+
icon: this.driverIcon(driver.code),
56+
}));
57+
}
58+
59+
get selectedDriver() {
60+
return this.availableDrivers.find((driver) => driver.code === this.args.resource?.driver);
61+
}
62+
63+
get selectedDriverIcon() {
64+
return this.driverIcon(this.selectedDriver?.code);
65+
}
66+
67+
get hasRequiredConfig() {
68+
const requiredFields = this.configSchema.filter((field) => field.required);
69+
70+
return requiredFields.every((field) => {
71+
const value = this.configValues[field.key];
72+
return value !== null && value !== undefined && String(value).trim() !== '';
73+
});
74+
}
75+
76+
get connectionState() {
77+
if (this.testCredentials.isRunning) {
78+
return 'testing';
79+
}
80+
81+
if (!this.connectionTestResult) {
82+
return 'idle';
83+
}
84+
85+
return this.connectionTestResult.success ? 'success' : 'failed';
86+
}
87+
88+
get connectionStateTitle() {
89+
switch (this.connectionState) {
90+
case 'testing':
91+
return 'Testing gateway credentials...';
92+
case 'success':
93+
return 'Gateway verified';
94+
case 'failed':
95+
return 'Gateway test failed';
96+
default:
97+
return this.args.resource?.id ? 'Ready to verify' : 'Save to verify';
98+
}
99+
}
100+
101+
get connectionStateMessage() {
102+
if (!this.args.resource?.id) {
103+
return 'Create the gateway first, then run live credential checks and webhook registration from the gateway diagnostics screen.';
104+
}
105+
106+
if (this.connectionState === 'idle') {
107+
return 'Run a credential test before using this gateway for invoice payments.';
108+
}
109+
110+
if (this.connectionState === 'testing') {
111+
return 'Ledger is checking the configured credentials with the payment provider.';
112+
}
113+
114+
return this.connectionTestResult?.message ?? 'The gateway credential check completed.';
115+
}
116+
117+
get connectionMetadataEntries() {
118+
return Object.entries(this.connectionTestResult?.metadata ?? {}).map(([key, value]) => ({
119+
label: key.replaceAll('_', ' '),
120+
value,
121+
}));
122+
}
123+
124+
get reviewConfigEntries() {
125+
return this.configSchema.map((field) => ({
126+
label: field.label,
127+
value: field.type === 'password' && this.configValues[field.key] ? '••••••••' : this.configValues[field.key],
128+
}));
129+
}
130+
20131
@task *loadDrivers() {
21132
try {
22133
const response = yield this.fetch.get('gateways/drivers', {}, { namespace: 'ledger/int/v1' });
23134
// The endpoint returns { status: 'ok', drivers: [...] }
24135
this.availableDrivers = response?.drivers ?? response ?? [];
25136

26-
// If the resource already has a driver selected, load its schema
27137
if (this.args.resource?.driver) {
28138
yield this.loadSchema.perform(this.args.resource.driver);
139+
} else {
140+
const initialDriver = this.args.initialDriverCode ? this.availableDrivers.find((driver) => driver.code === this.args.initialDriverCode) : this.availableDrivers[0];
141+
if (initialDriver) {
142+
this.selectDriver(initialDriver);
143+
}
29144
}
30145
} catch (err) {
31146
this.notifications.warning('Could not load available payment drivers.');
@@ -56,21 +171,97 @@ export default class GatewayFormComponent extends Component {
56171
}
57172

58173
@action selectDriver(driver) {
59-
this.args.resource.driver = driver.code;
174+
if (!this.canSelectDriver) {
175+
return;
176+
}
177+
178+
this.args.resource.set?.('driver', driver.code);
179+
this.args.resource.set?.('name', this.args.resource?.name || driver.name);
180+
this.args.resource.set?.('code', this.args.resource?.code || driver.code);
60181
// Sync the driver's capabilities to the resource so they are persisted
61182
if (driver.capabilities) {
62-
this.args.resource.capabilities = driver.capabilities;
183+
this.args.resource.set?.('capabilities', driver.capabilities);
63184
}
64185
this.loadSchema.perform(driver.code);
186+
this.connectionTestResult = null;
65187
}
66188

67189
@action updateConfigField(key, value) {
68190
// When bound via {{on "input" (fn this.updateConfigField field.key)}} the second
69191
// argument is a DOM InputEvent, not the raw string value. Extract the actual
70192
// value from event.target.value in that case so credentials are stored correctly.
71-
const resolvedValue = value instanceof Event ? value.target?.value ?? value : value;
193+
const resolvedValue = value instanceof Event ? (value.target?.value ?? value) : (value?.value ?? value);
72194
this.configValues = { ...this.configValues, [key]: resolvedValue };
73195
// Persist config back to the resource
74-
this.args.resource.config = { ...this.configValues };
196+
this.args.resource.set?.('config', { ...this.configValues });
197+
this.connectionTestResult = null;
198+
}
199+
200+
@task *testCredentials() {
201+
if (!this.args.resource?.id) {
202+
this.notifications.info('Save this gateway before testing credentials.');
203+
return;
204+
}
205+
206+
try {
207+
this.connectionTestResult = yield this.fetch.post(`gateways/${this.args.resource.id}/test-credentials`, {}, { namespace: 'ledger/int/v1' });
208+
} catch (error) {
209+
this.connectionTestResult = {
210+
success: false,
211+
message: error.message ?? 'Gateway credential test failed.',
212+
metadata: {},
213+
};
214+
this.notifications.serverError(error);
215+
}
216+
}
217+
218+
@action setResourceField(field, value) {
219+
const resolvedValue = value instanceof Event ? (value.target?.value ?? value) : (value?.value ?? value);
220+
this.args.resource?.set?.(field, resolvedValue);
221+
}
222+
223+
@action goToStep(index) {
224+
this.activeStep = index;
225+
}
226+
227+
@action nextStep() {
228+
this.activeStep = Math.min(this.activeStep + 1, this.setupSteps.length - 1);
229+
}
230+
231+
@action previousStep() {
232+
this.activeStep = Math.max(this.activeStep - 1, 0);
233+
}
234+
235+
@action copyWebhookUrl() {
236+
const url = this.args.resource?.system_webhook_url ?? this.args.resource?.webhook_url;
237+
if (!url) {
238+
return this.notifications.warning('No webhook URL is available yet.');
239+
}
240+
241+
copyToClipboard(url)
242+
.then(() => this.notifications.success('Webhook URL copied.'))
243+
.catch(() => this.notifications.error('Unable to copy webhook URL.'));
244+
}
245+
246+
driverCategory(code) {
247+
return (
248+
{
249+
taler: 'Wallet payment',
250+
stripe: 'Cards and wallets',
251+
cash: 'Manual payment',
252+
qpay: 'Regional payment',
253+
}[code] ?? 'Payment gateway'
254+
);
255+
}
256+
257+
driverIcon(code) {
258+
return (
259+
{
260+
taler: 'wallet',
261+
stripe: 'credit-card',
262+
cash: 'money-bill-wave',
263+
qpay: 'qrcode',
264+
}[code] ?? 'plug'
265+
);
75266
}
76267
}

addon/components/gateway/hub.hbs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div class="mx-auto flex w-full max-w-7xl flex-col gap-3 md:flex-row md:items-start md:justify-between">
55
<div class="min-w-0">
66
<div class="flex flex-wrap items-center gap-2">
7-
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Payment Gateway Hub</h1>
7+
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Payment Gateways</h1>
88
<Badge @status={{if this.warningCount "warning" "success"}} @hideStatusDot={{true}}>{{this.warningCount}} warnings</Badge>
99
</div>
1010
<p class="mt-1 max-w-3xl text-sm leading-5 text-gray-500 dark:text-gray-300">
@@ -26,13 +26,13 @@
2626
</section>
2727

2828
{{#if this.hasGatewayConnections}}
29-
<section class="min-w-0 overflow-hidden rounded-md border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
30-
<div class="grid gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700 lg:grid-cols-[minmax(0,26rem)_minmax(22rem,max-content)] lg:items-start lg:justify-between">
29+
<section class="ledger-gateway-connections-panel min-w-0 overflow-hidden rounded-md border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
30+
<div class="ledger-gateway-connections-header border-b border-gray-200 px-4 py-3 dark:border-gray-700">
3131
<div class="min-w-0 max-w-md">
3232
<h2 class="text-sm font-bold text-gray-900 dark:text-gray-100">Connected Gateways</h2>
3333
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-300">Review configured providers, checkout availability, environments, and operational status.</p>
3434
</div>
35-
<div class="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap lg:justify-end">
35+
<div class="ledger-gateway-connections-actions flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap lg:justify-end">
3636
<Layout::Resource::TabularActions
3737
@title="Connected Gateways"
3838
@searchQuery={{@searchQuery}}
@@ -41,6 +41,9 @@
4141
@bulkActions={{@bulkActions}}
4242
@controller={{@controller}}
4343
@table={{this.table}}
44+
@searchInputClass="ledger-gateway-connections-search"
45+
@filterPickerButtonClass="ledger-gateway-connections-action-button"
46+
@columnPickerButtonClass="ledger-gateway-connections-action-button"
4447
/>
4548
</div>
4649
</div>

addon/components/gateway/hub.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,28 @@ export default class GatewayHubComponent extends Component {
9191
value: this.summary.active_gateways ?? 0,
9292
caption: 'Ready to collect payments',
9393
icon: 'plug',
94+
accentClass: Number(this.summary.active_gateways ?? 0) > 0 ? 'ledger-gateway-kpi-accent-green' : 'ledger-gateway-kpi-accent-blue',
9495
},
9596
{
9697
label: 'Live ready',
9798
value: this.summary.live_gateways ?? 0,
9899
caption: 'Production connections',
99100
icon: 'bolt',
101+
accentClass: Number(this.summary.live_gateways ?? 0) > 0 ? 'ledger-gateway-kpi-accent-blue' : 'ledger-gateway-kpi-accent-slate',
100102
},
101103
{
102104
label: 'Webhook issues',
103105
value: this.warningCount,
104106
caption: this.warningCount > 0 ? 'Need provisioning review' : 'No open warnings',
105107
icon: 'link',
108+
accentClass: this.warningCount > 0 ? 'ledger-gateway-kpi-accent-amber' : 'ledger-gateway-kpi-accent-green',
106109
},
107110
{
108111
label: 'Sandbox',
109112
value: this.summary.sandbox_gateways ?? 0,
110113
caption: 'Testing connections',
111114
icon: 'flask',
115+
accentClass: Number(this.summary.sandbox_gateways ?? 0) > 0 ? 'ledger-gateway-kpi-accent-amber' : 'ledger-gateway-kpi-accent-slate',
112116
},
113117
];
114118
}
@@ -130,6 +134,6 @@ export default class GatewayHubComponent extends Component {
130134
return this.gatewayActions.transition.view(driver.primaryGateway);
131135
}
132136

133-
return this.gatewayActions.transition.create();
137+
return this.gatewayActions.transition.create(driver.code);
134138
}
135139
}

addon/components/gateway/status-tile.hbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
<div class="relative flex min-h-[112px] flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800" ...attributes>
1+
<div class="ledger-gateway-kpi-tile {{@tile.accentClass}} relative flex min-h-[112px] flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800" ...attributes>
22
<div class="flex h-full flex-col justify-between p-3">
33
<div class="flex items-start justify-between gap-2">
44
<div class="min-w-0">
55
<div class="truncate text-[10px] font-bold uppercase text-gray-500 dark:text-gray-400">{{@tile.label}}</div>
66
<div class="mt-2 truncate text-2xl font-extrabold leading-none text-gray-900 dark:text-gray-50">{{@tile.value}}</div>
77
</div>
8-
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
8+
<div class="ledger-gateway-kpi-icon flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
99
<FaIcon @icon={{@tile.icon}} />
1010
</div>
1111
</div>

addon/controllers/payments/gateways/index/details.js renamed to addon/controllers/payments/gateways/details.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,31 @@ import { inject as service } from '@ember/service';
33
import { action } from '@ember/object';
44
import { tracked } from '@glimmer/tracking';
55

6-
export default class PaymentsGatewaysIndexDetailsController extends Controller {
6+
export default class PaymentsGatewaysDetailsController extends Controller {
77
@service notifications;
88
@service modalsManager;
99
@service hostRouter;
1010

1111
@tracked overlay = null;
1212

1313
get tabs() {
14+
const currentRouteName = this.hostRouter.currentRouteName;
15+
1416
return [
15-
{ label: 'Overview', route: 'payments.gateways.index.details.index' },
16-
{ label: 'Setup', route: 'payments.gateways.index.details.setup' },
17-
{ label: 'Diagnostics', route: 'payments.gateways.index.details.diagnostics' },
18-
{ label: 'Transactions', route: 'payments.gateways.index.details.webhooks' },
19-
];
17+
{ label: 'Overview', route: 'payments.gateways.details.index' },
18+
{ label: 'Setup', route: 'payments.gateways.details.setup' },
19+
{ label: 'Diagnostics', route: 'payments.gateways.details.diagnostics' },
20+
{ label: 'Transactions', route: 'payments.gateways.details.webhooks' },
21+
].map((tab) => ({
22+
...tab,
23+
active: currentRouteName === tab.route || currentRouteName?.endsWith(`.${tab.route}`),
24+
}));
25+
}
26+
27+
get isTransactionsTab() {
28+
const currentRouteName = this.hostRouter.currentRouteName;
29+
30+
return currentRouteName === 'payments.gateways.details.webhooks' || currentRouteName?.endsWith('.payments.gateways.details.webhooks');
2031
}
2132

2233
get actionButtons() {
@@ -43,7 +54,7 @@ export default class PaymentsGatewaysIndexDetailsController extends Controller {
4354

4455
@action editGateway() {
4556
const gateway = this.model;
46-
this.hostRouter.transitionTo('console.ledger.payments.gateways.index.edit', gateway);
57+
this.hostRouter.transitionTo('console.ledger.payments.gateways.edit', gateway);
4758
}
4859

4960
@action async deleteGateway() {

0 commit comments

Comments
 (0)