Skip to content

Commit d5e5c2f

Browse files
committed
Revamp payment gateway management UI
1 parent 9957f3b commit d5e5c2f

33 files changed

Lines changed: 802 additions & 197 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<button
2+
type="button"
3+
class="group flex min-h-[150px] flex-col rounded-md border border-gray-200 bg-white p-3 text-left transition hover:border-blue-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-400"
4+
{{on "click" (fn @onClick @driver)}}
5+
...attributes
6+
>
7+
<div class="flex items-start justify-between gap-3">
8+
<div class="flex min-w-0 items-start gap-3">
9+
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200">
10+
<FaIcon @icon={{@driver.icon}} />
11+
</div>
12+
<div class="min-w-0">
13+
<div class="truncate text-sm font-bold text-gray-900 dark:text-gray-100">{{@driver.name}}</div>
14+
<div class="truncate text-[10px] uppercase text-gray-400 dark:text-gray-500">{{@driver.type}}</div>
15+
</div>
16+
</div>
17+
<Badge @status={{if @driver.connected "success" "info"}} @hideStatusDot={{true}}>{{if @driver.connected "Connected" "Available"}}</Badge>
18+
</div>
19+
20+
<p class="mt-3 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-300">{{@driver.description}}</p>
21+
22+
<div class="mt-3 flex flex-wrap gap-1">
23+
{{#each @driver.capabilities as |capability|}}
24+
<Badge @color="blue">{{capability}}</Badge>
25+
{{else}}
26+
<Badge @color="gray">Manual</Badge>
27+
{{/each}}
28+
</div>
29+
30+
<div class="mt-auto flex items-center justify-between pt-3 text-xs font-semibold text-blue-600 dark:text-blue-300">
31+
<span>{{if @driver.connected "Manage gateway" "Connect gateway"}}</span>
32+
<FaIcon @icon="arrow-right" class="transition group-hover:translate-x-0.5" />
33+
</div>
34+
</button>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Component from '@glimmer/component';
2+
3+
export default class GatewayCatalogCardComponent extends Component {}

addon/components/gateway/details.hbs

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

addon/components/gateway/details.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,101 @@ export default class GatewayDetailsComponent extends Component {
2323
return this.args.resource?.id;
2424
}
2525

26+
get section() {
27+
return this.args.section ?? 'overview';
28+
}
29+
30+
get showOverview() {
31+
return this.section === 'overview';
32+
}
33+
34+
get showSetup() {
35+
return this.section === 'setup';
36+
}
37+
38+
get showDiagnostics() {
39+
return this.section === 'diagnostics';
40+
}
41+
42+
get driverLabel() {
43+
const driver = this.args.resource?.driver;
44+
45+
if (!driver) {
46+
return 'Gateway';
47+
}
48+
49+
if (driver === 'taler') {
50+
return 'GNU Taler';
51+
}
52+
53+
if (driver === 'qpay') {
54+
return 'QPay';
55+
}
56+
57+
return driver.charAt(0).toUpperCase() + driver.slice(1);
58+
}
59+
60+
get webhookStatus() {
61+
return this.diagnostics?.diagnostics?.webhook_registration ?? (this.args.resource?.webhook_url ? 'configured' : 'not_configured');
62+
}
63+
64+
get lastWebhookAt() {
65+
return this.diagnostics?.diagnostics?.last_webhook_received_at;
66+
}
67+
68+
get lastPaymentAt() {
69+
return this.diagnostics?.diagnostics?.last_payment_event_at;
70+
}
71+
72+
get lastRefundAt() {
73+
return this.diagnostics?.diagnostics?.last_refund_event_at;
74+
}
75+
76+
get lastSettlementAt() {
77+
return this.diagnostics?.diagnostics?.last_settlement_seen_at;
78+
}
79+
80+
get reconciliationStatus() {
81+
return this.diagnostics?.diagnostics?.last_reconciliation_status;
82+
}
83+
84+
get readinessItems() {
85+
return [
86+
{
87+
label: 'Connection',
88+
status: this.args.resource?.status === 'active' ? 'ready' : 'inactive',
89+
icon: 'plug',
90+
},
91+
{
92+
label: 'Environment',
93+
status: this.args.resource?.environment ?? 'unknown',
94+
icon: 'server',
95+
},
96+
{
97+
label: 'Webhook',
98+
status: this.webhookStatus === 'configured' ? 'configured' : 'needs setup',
99+
icon: 'link',
100+
},
101+
{
102+
label: 'Settlement',
103+
status: this.reconciliationStatus ?? 'not checked',
104+
icon: 'scale-balanced',
105+
},
106+
];
107+
}
108+
109+
get checkoutPreviewLabel() {
110+
if (this.args.resource?.driver === 'cash') {
111+
return 'Manual payment instructions';
112+
}
113+
114+
if (this.args.resource?.driver === 'taler') {
115+
return 'Wallet redirect and QR payment';
116+
}
117+
118+
return 'Online invoice payment option';
119+
}
120+
26121
@task({ restartable: true })
27122
*loadDiagnostics() {
28123
if (!this.gatewayId) return;

addon/components/gateway/hub.hbs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<div class="h-full overflow-y-auto bg-gray-50 dark:bg-gray-900" ...attributes>
2+
<div class="flex w-full flex-col">
3+
<section class="sticky top-0 z-50 border-b border-gray-200 bg-white px-4 py-4 shadow-sm dark:border-gray-700 dark:bg-gray-900 md:px-6">
4+
<div class="mx-auto flex w-full max-w-7xl flex-col gap-3 md:flex-row md:items-start md:justify-between">
5+
<div class="min-w-0">
6+
<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>
8+
<Badge @status={{if this.warningCount "warning" "success"}} @hideStatusDot={{true}}>{{this.warningCount}} warnings</Badge>
9+
</div>
10+
<p class="mt-1 max-w-3xl text-sm leading-5 text-gray-500 dark:text-gray-300">
11+
Configure checkout providers, manual methods, webhooks, refunds, and settlement readiness from one workflow.
12+
</p>
13+
</div>
14+
<div class="flex flex-shrink-0 items-center gap-2 whitespace-nowrap">
15+
<Button @icon={{if @summaryLoading "spinner" "refresh"}} @iconSpin={{@summaryLoading}} @size="sm" @helpText={{t "common.reload"}} @onClick={{this.refresh}} />
16+
<Button @icon="plus" @text="Add Gateway" @type="primary" @size="sm" @onClick={{this.gatewayActions.transition.create}} />
17+
</div>
18+
</div>
19+
</section>
20+
21+
<div class="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-4 md:px-6">
22+
<section class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
23+
{{#each this.statusTiles as |tile|}}
24+
<Gateway::StatusTile @tile={{tile}} />
25+
{{/each}}
26+
</section>
27+
28+
{{#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">
31+
<div class="min-w-0 max-w-md">
32+
<h2 class="text-sm font-bold text-gray-900 dark:text-gray-100">Connected Gateways</h2>
33+
<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>
34+
</div>
35+
<div class="flex min-w-0 flex-wrap items-center gap-2 lg:ml-auto lg:flex-nowrap lg:justify-end">
36+
<Layout::Resource::TabularActions
37+
@title="Connected Gateways"
38+
@searchQuery={{@searchQuery}}
39+
@onSearch={{@onSearch}}
40+
@columns={{@columns}}
41+
@bulkActions={{@bulkActions}}
42+
@controller={{@controller}}
43+
@table={{this.table}}
44+
/>
45+
</div>
46+
</div>
47+
<Layout::Resource::Tabular
48+
@resource="ledger-gateway"
49+
@title=""
50+
@withoutHeader={{true}}
51+
@searchQuery={{@searchQuery}}
52+
@onSearch={{@onSearch}}
53+
@data={{@gateways}}
54+
@columns={{@columns}}
55+
@page={{@page}}
56+
@onPageChange={{@onPageChange}}
57+
@bulkActions={{@bulkActions}}
58+
@controller={{@controller}}
59+
@setupTable={{this.setupTable}}
60+
/>
61+
</section>
62+
{{/if}}
63+
64+
<section class="rounded-md border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
65+
<div class="mb-3 flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
66+
<div>
67+
<h2 class="text-sm font-bold text-gray-900 dark:text-gray-100">Supported Gateways</h2>
68+
<p class="text-xs leading-5 text-gray-500 dark:text-gray-300">Connect supported gateway drivers or manage existing configured accounts.</p>
69+
</div>
70+
{{#if @summaryLoading}}
71+
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-300"><Spinner /> Loading gateway status</div>
72+
{{/if}}
73+
</div>
74+
75+
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
76+
{{#each this.driverCards as |driver|}}
77+
<Gateway::CatalogCard @driver={{driver}} @onClick={{this.openDriver}} />
78+
{{/each}}
79+
</div>
80+
</section>
81+
</div>
82+
</div>
83+
</div>

addon/components/gateway/hub.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import Component from '@glimmer/component';
2+
import { tracked } from '@glimmer/tracking';
3+
import { inject as service } from '@ember/service';
4+
import { action } from '@ember/object';
5+
6+
const DRIVER_COPY = {
7+
taler: {
8+
name: 'GNU Taler',
9+
icon: 'wallet',
10+
type: 'Wallet payment',
11+
description: 'Privacy-preserving wallet payments, QR checkout, webhook confirmation, refunds, and settlement verification.',
12+
},
13+
stripe: {
14+
name: 'Stripe',
15+
icon: 'credit-card',
16+
type: 'Cards and wallets',
17+
description: 'Card and digital wallet processing with webhook confirmation and refund support.',
18+
},
19+
cash: {
20+
name: 'Cash',
21+
icon: 'money-bill-wave',
22+
type: 'Manual payment',
23+
description: 'Record offline or in-person payments without external webhook provisioning.',
24+
},
25+
qpay: {
26+
name: 'QPay',
27+
icon: 'qrcode',
28+
type: 'Regional payment',
29+
description: 'Regional gateway support for QR and local payment collection flows.',
30+
},
31+
};
32+
33+
export default class GatewayHubComponent extends Component {
34+
@service gatewayActions;
35+
@tracked table;
36+
37+
get gateways() {
38+
return Array.from(this.args.gateways ?? []);
39+
}
40+
41+
get summary() {
42+
return this.args.summary?.summary ?? {};
43+
}
44+
45+
get drivers() {
46+
return Array.from(this.args.drivers ?? []);
47+
}
48+
49+
get driverCards() {
50+
const configuredByDriver = new Map();
51+
52+
this.gateways.forEach((gateway) => {
53+
const list = configuredByDriver.get(gateway.driver) ?? [];
54+
list.push(gateway);
55+
configuredByDriver.set(gateway.driver, list);
56+
});
57+
58+
return ['taler', 'stripe', 'cash', 'qpay'].map((code) => {
59+
const manifest = this.drivers.find((driver) => driver.code === code) ?? {};
60+
const copy = DRIVER_COPY[code];
61+
const configured = configuredByDriver.get(code) ?? [];
62+
const active = configured.filter((gateway) => gateway.status === 'active');
63+
64+
return {
65+
code,
66+
name: manifest.name ?? copy.name,
67+
icon: copy.icon,
68+
type: copy.type,
69+
description: copy.description,
70+
capabilities: manifest.capabilities ?? [],
71+
configuredCount: configured.length,
72+
activeCount: active.length,
73+
primaryGateway: configured[0],
74+
connected: configured.length > 0,
75+
};
76+
});
77+
}
78+
79+
get hasGatewayConnections() {
80+
return this.gateways.length > 0;
81+
}
82+
83+
get warningCount() {
84+
return Number(this.summary.webhook_warnings ?? 0);
85+
}
86+
87+
get statusTiles() {
88+
return [
89+
{
90+
label: 'Active gateways',
91+
value: this.summary.active_gateways ?? 0,
92+
caption: 'Ready to collect payments',
93+
icon: 'plug',
94+
},
95+
{
96+
label: 'Live ready',
97+
value: this.summary.live_gateways ?? 0,
98+
caption: 'Production connections',
99+
icon: 'bolt',
100+
},
101+
{
102+
label: 'Webhook issues',
103+
value: this.warningCount,
104+
caption: this.warningCount > 0 ? 'Need provisioning review' : 'No open warnings',
105+
icon: 'link',
106+
},
107+
{
108+
label: 'Sandbox',
109+
value: this.summary.sandbox_gateways ?? 0,
110+
caption: 'Testing connections',
111+
icon: 'flask',
112+
},
113+
];
114+
}
115+
116+
@action setupTable(table) {
117+
this.table = table;
118+
}
119+
120+
@action refresh() {
121+
if (typeof this.args.refresh === 'function') {
122+
this.args.refresh();
123+
}
124+
125+
return this.gatewayActions.refresh();
126+
}
127+
128+
@action openDriver(driver) {
129+
if (driver.primaryGateway) {
130+
return this.gatewayActions.transition.view(driver.primaryGateway);
131+
}
132+
133+
return this.gatewayActions.transition.create();
134+
}
135+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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>
2+
<div class="flex h-full flex-col justify-between p-3">
3+
<div class="flex items-start justify-between gap-2">
4+
<div class="min-w-0">
5+
<div class="truncate text-[10px] font-bold uppercase text-gray-500 dark:text-gray-400">{{@tile.label}}</div>
6+
<div class="mt-2 truncate text-2xl font-extrabold leading-none text-gray-900 dark:text-gray-50">{{@tile.value}}</div>
7+
</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">
9+
<FaIcon @icon={{@tile.icon}} />
10+
</div>
11+
</div>
12+
<div class="mt-3 truncate text-[11px] text-gray-500 dark:text-gray-400">{{@tile.caption}}</div>
13+
</div>
14+
</div>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import Component from '@glimmer/component';
2+
3+
export default class GatewayStatusTileComponent extends Component {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<button type="button" class="group flex w-full min-w-0 cursor-pointer items-start gap-2 py-1 text-left" {{on "click" this.onClick}} ...attributes>
2+
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-gray-200 bg-gray-50 text-gray-700 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200">
3+
<FaIcon @icon={{this.icon}} />
4+
</div>
5+
<div class="min-w-0 text-left">
6+
<div class="truncate text-sm font-semibold text-gray-900 group-hover:text-blue-600 dark:text-gray-100 dark:group-hover:text-blue-300">{{n-a this.gateway.name}}</div>
7+
<div class="mt-0.5 max-w-[240px] truncate whitespace-nowrap text-left text-xs leading-4 text-gray-500 dark:text-gray-400">{{n-a this.subtitle}}</div>
8+
</div>
9+
</button>

0 commit comments

Comments
 (0)