Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions frontend/app/[locale]/admin/shop/orders/[id]/CancelPaymentButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
'use client';

import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useId, useState, useTransition } from 'react';

type Props = {
orderId: string;
disabled: boolean;
csrfToken: string;
};

function normalizeActionErrorCode(error: unknown): string {
if (error instanceof TypeError) {
return 'NETWORK_ERROR';
}

if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}

return 'NETWORK_ERROR';
}

function mapCancelPaymentError(
code: string,
t: (key: string) => string
): string {
switch (code) {
case 'NETWORK_ERROR':
return t('errors.network');
case 'CSRF_REJECTED':
return t('errors.security');
case 'CANCEL_DISABLED':
return t('errors.cancelPaymentDisabled');
case 'CANCEL_PROVIDER_NOT_MONOBANK':
case 'CANCEL_NOT_ALLOWED':
return t('errors.cancelPaymentNotAvailable');
case 'CANCEL_MISSING_PROVIDER_REF':
return t('errors.missingPaymentReference');
case 'CANCEL_IN_PROGRESS':
return t('errors.cancelPaymentInProgress');
case 'PSP_UNAVAILABLE':
return t('errors.providerUnavailable');
case 'ADMIN_API_DISABLED':
return t('errors.adminDisabled');
case 'INTERNAL_ERROR':
case 'HTTP_500':
return t('errors.generic');
default:
return t('errors.generic');
}
}

export function CancelPaymentButton({ orderId, disabled, csrfToken }: Props) {
const router = useRouter();
const t = useTranslations('shop.orders.detail.paymentControls');
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const errorId = useId();

async function onCancelPayment() {
setError(null);

let res: Response;
try {
res = await fetch(`/api/shop/admin/orders/${orderId}/cancel-payment`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
});
} catch (err) {
setError(mapCancelPaymentError(normalizeActionErrorCode(err), t));
return;
}

let json: any = null;
try {
json = await res.json();
} catch {
// ignore
}

if (!res.ok) {
setError(
mapCancelPaymentError(
json?.error ?? json?.code ?? json?.message ?? `HTTP_${res.status}`,
t
)
);
return;
}

startTransition(() => {
router.refresh();
});
}

const isDisabled = disabled || isPending;

return (
<div className="space-y-2">
<button
type="button"
onClick={onCancelPayment}
disabled={isDisabled}
aria-busy={isPending}
aria-describedby={error ? errorId : undefined}
className="w-full rounded-lg border border-sky-500/30 bg-sky-500/5 px-3 py-2 text-left text-sm font-medium text-sky-100 transition-colors hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-50"
title={disabled ? t('onlyForUnpaidMonobank') : undefined}
>
{isPending ? t('cancelingPayment') : t('cancelUnpaidPayment')}
</button>

{error ? (
<span
id={errorId}
role="alert"
className="block rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-100"
>
{error}
</span>
) : null}
</div>
);
}
75 changes: 64 additions & 11 deletions frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,57 @@ type Props = {
csrfToken: string;
};

function normalizeActionErrorCode(error: unknown): string {
if (error instanceof TypeError) {
return 'NETWORK_ERROR';
}

if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}

return 'NETWORK_ERROR';
}

function mapRefundError(code: string, t: (key: string) => string): string {
switch (code) {
case 'NETWORK_ERROR':
return t('errors.network');
case 'CSRF_REJECTED':
return t('errors.security');
case 'REFUND_PROVIDER_NOT_STRIPE':
case 'REFUND_ORDER_NOT_PAID':
return t('errors.refundNotAvailable');
case 'REFUND_MISSING_PSP_TARGET':
return t('errors.missingPaymentReference');
case 'REFUND_ORDER_MONEY_INVALID':
return t('errors.invalidAmount');
case 'PSP_UNAVAILABLE':
return t('errors.providerUnavailable');
case 'ADMIN_API_DISABLED':
return t('errors.adminDisabled');
case 'INTERNAL_ERROR':
case 'HTTP_500':
return t('errors.generic');
default:
return t('errors.generic');
}
}

export function RefundButton({ orderId, disabled, csrfToken }: Props) {
const router = useRouter();
const t = useTranslations('shop.admin.refund');
const t = useTranslations('shop.orders.detail.paymentControls');
const [isPending, startTransition] = useTransition();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorId = useId();

async function onRefund() {
if (disabled || isSubmitting || isPending) {
return;
}

setIsSubmitting(true);
setError(null);

let res: Response;
Expand All @@ -31,9 +74,8 @@ export function RefundButton({ orderId, disabled, csrfToken }: Props) {
},
});
} catch (err) {
const msg =
err instanceof Error && err.message ? err.message : 'NETWORK_ERROR';
setError(msg);
setError(mapRefundError(normalizeActionErrorCode(err), t));
setIsSubmitting(false);
return;
}

Expand All @@ -45,33 +87,44 @@ export function RefundButton({ orderId, disabled, csrfToken }: Props) {
}

if (!res.ok) {
setError(json?.error ?? json?.code ?? `HTTP_${res.status}`);
setError(
mapRefundError(
json?.error ?? json?.code ?? json?.message ?? `HTTP_${res.status}`,
t
)
);
setIsSubmitting(false);
return;
}

setIsSubmitting(false);
startTransition(() => {
router.refresh();
});
}

const isDisabled = disabled || isPending;
const isDisabled = disabled || isSubmitting || isPending;

return (
<div className="flex items-center gap-2">
<div className="space-y-2">
<button
type="button"
onClick={onRefund}
disabled={isDisabled}
aria-busy={isPending}
aria-busy={isSubmitting || isPending}
aria-describedby={error ? errorId : undefined}
className="border-border text-foreground hover:bg-secondary rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
title={disabled ? t('onlyForPaid') : undefined}
className="w-full rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-left text-sm font-medium text-amber-100 transition-colors hover:bg-amber-500/10 disabled:cursor-not-allowed disabled:opacity-50"
title={disabled ? t('onlyForPaidStripe') : undefined}
>
{isPending ? t('refunding') : t('refund')}
</button>

{error ? (
<span id={errorId} role="alert" className="text-destructive text-xs">
<span
id={errorId}
role="alert"
className="block rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-amber-100"
>
{error}
</span>
) : null}
Expand Down
Loading
Loading