commit bf8b800bf1ee2fdb206a1ecfaba59a1f0031b55e parent b15cf80c3448501d042f34b542ed7c34314261c3 Author: Sebastian <sebasjm@gmail.com> Date: Fri, 2 Apr 2021 17:23:10 -0300 better error handling Diffstat:
25 files changed, 522 insertions(+), 391 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - order id field to go frontend, too many redirects +BUGS TEST CASES: +https://git.taler.net/anastasis.git/tree/src/cli/test_anastasis_reducer_enter_secret.sh - navigation to another instance should not do full refresh - cleanup instance and token management, because code is a mess and can be refactored diff --git a/packages/frontend/src/ApplicationReadyRoutes.tsx b/packages/frontend/src/ApplicationReadyRoutes.tsx @@ -36,67 +36,52 @@ export function ApplicationReadyRoutes(): VNode { changeBackend(url); if (token) updateToken(token); }; - const list = useBackendInstancesTestForAdmin() + const result = useBackendInstancesTestForAdmin() const clearTokenAndGoToRoot = () => { clearAllTokens(); route('/') } - if (!list.data) { - if (list.unauthorized) { - return <Fragment> - <NotYetReadyAppMenu title="Login" - onLogout={clearTokenAndGoToRoot} - /> - <NotificationCard notification={{ - message: i18n`Access denied`, - description: i18n`Check your token is valid`, - type: 'ERROR' - }} - /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - } - if (list.notfound) { - const path = new URL(backendURL).pathname - const match = INSTANCE_ID_LOOKUP.exec(path) - if (!match || !match[1]) { - // this should be rare because - // query to /config is ok but the URL - // doest not match with our pattern - return <Fragment> - <NotYetReadyAppMenu title="Error" onLogout={clearTokenAndGoToRoot} /> - <NotificationCard notification={{ - message: i18n`Couldn't access the server`, - description: i18n`Could not infer instance id from url ${backendURL}`, - type: 'ERROR', - }} - /> - <LoginPage onConfirm={updateLoginStatus} /> - </Fragment> - } + if (result.clientError && result.isUnauthorized) { + return <Fragment> + <NotYetReadyAppMenu title="Login" onLogout={clearTokenAndGoToRoot} /> + <NotificationCard notification={{ + message: i18n`Access denied`, + description: i18n`Check your token is valid`, + type: 'ERROR' + }} + /> + <LoginPage onConfirm={updateLoginStatus} /> + </Fragment> + } - return <Fragment> - <Menu instance={match[1]} onLogout={clearTokenAndGoToRoot} /> - <InstanceRoutes id={match[1]} /> - </Fragment> - } - if (list.error) { + if (result.loading) return <NotYetReadyAppMenu title="Loading..." /> + + if (!result.ok) { + const path = new URL(backendURL).pathname + const match = INSTANCE_ID_LOOKUP.exec(path) + if (!match || !match[1]) { + // this should be rare because + // query to /config is ok but the URL + // doest not match with our pattern return <Fragment> - <NotYetReadyAppMenu title="Error" /> + <NotYetReadyAppMenu title="Error" onLogout={clearTokenAndGoToRoot} /> <NotificationCard notification={{ - message: i18n`Couldn't access the server`, - description: list.error.message, - type: 'ERROR' - }} /> + message: i18n`Couldn't access the server.`, + description: i18n`Could not infer instance id from url ${backendURL}`, + type: 'ERROR', + }} + /> <LoginPage onConfirm={updateLoginStatus} /> </Fragment> } - // is loading - return <NotYetReadyAppMenu title="Loading..." /> + return <Fragment> + <Menu instance={match[1]} onLogout={clearTokenAndGoToRoot} /> + <InstanceRoutes id={match[1]} /> + </Fragment> } let instance @@ -106,6 +91,7 @@ export function ApplicationReadyRoutes(): VNode { } finally { if (!instance) instance = 'default' } + return <Fragment> <Menu instance={instance} admin onLogout={clearTokenAndGoToRoot} /> <InstanceRoutes admin id={instance} /> diff --git a/packages/frontend/src/InstanceRoutes.tsx b/packages/frontend/src/InstanceRoutes.tsx @@ -26,7 +26,7 @@ import { useMessageTemplate } from 'preact-messages'; import { createHashHistory } from 'history'; import { useBackendDefaultToken, useBackendInstanceToken } from './hooks'; import { InstanceContextProvider, useBackendContext } from './context/backend'; -import { SwrError } from "./hooks/backend"; +import { HttpError, HttpResponseServerError, RequestInfo, SwrError } from "./hooks/backend"; // import { Notification } from './utils/types'; import LoginPage from './paths/login'; @@ -53,6 +53,7 @@ import InstanceListPage from './paths/admin/list'; import InstanceCreatePage from "./paths/admin/create"; import { NotificationCard } from './components/menu'; import { Loading } from './components/exception/loading'; +import { MerchantBackend } from './declaration'; export enum InstancePaths { // details = '/', @@ -109,8 +110,8 @@ export function InstanceRoutes({ id, admin }: Props): VNode { const value = useMemo(() => ({ id, token, admin }), [id, token]) - const LoginPageServerError = (error: SwrError) => <Fragment> - <NotificationCard notification={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR' }} /> + const LoginPageServerError = (error: HttpError) => <Fragment> + <NotificationCard notification={{ message: `Server reported a problem: HTTP status #${error.status}`, description: `Got message: ${error.message} from: ${error.info?.url}`, type: 'ERROR' }} /> <LoginPage onConfirm={updateLoginStatus} /> </Fragment> @@ -120,15 +121,15 @@ export function InstanceRoutes({ id, admin }: Props): VNode { </Fragment> function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { - return (props?:T)=> { - if (admin) { + return (props?: T) => { + if (admin && id === 'default') { return <Fragment> <NotificationCard notification={{ message: 'No default instance', description: 'in order to use merchant backoffice, you should create the default instance', type: 'INFO' }} /> - <InstanceCreatePage onError={() => null} forceId="default" onConfirm={() => { + <InstanceCreatePage forceId="default" onConfirm={() => { route(AdminPaths.list_instances) }} /> </Fragment> @@ -161,7 +162,6 @@ export function InstanceRoutes({ id, admin }: Props): VNode { <Route path={AdminPaths.new_instance} component={InstanceCreatePage} onBack={() => route(AdminPaths.list_instances)} onConfirm={() => { route(AdminPaths.list_instances); }} - onError={LoginPageServerError} /> } @@ -169,7 +169,8 @@ export function InstanceRoutes({ id, admin }: Props): VNode { <Route path={AdminPaths.update_instance} component={AdminInstanceUpdatePage} onBack={() => route(AdminPaths.list_instances)} onConfirm={() => { route(AdminPaths.list_instances); }} - onLoadError={LoginPageServerError} + onUpdateError={(e: Error) => { }} + onNotFound={NotFoundPage} /> } @@ -177,12 +178,12 @@ export function InstanceRoutes({ id, admin }: Props): VNode { * Update instance page */} <Route path={InstancePaths.update} component={InstanceUpdatePage} - onUnauthorized={LoginPageAccessDenied} - onLoadError={IfAdminCreateDefaultOr(LoginPageServerError)} + onBack={() => { route(`/`); }} + onConfirm={() => { route(`/`); }} + onUpdateError={(e: Error) => { }} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => {route(`/`);}} - onConfirm={() => {route(`/`);}} - onUpdateError={(e: Error) => {}} + onUnauthorized={LoginPageAccessDenied} + onLoadError={LoginPageServerError} /> {/** @@ -190,7 +191,7 @@ export function InstanceRoutes({ id, admin }: Props): VNode { */} <Route path={InstancePaths.product_list} component={ProductListPage} onUnauthorized={LoginPageAccessDenied} - onLoadError={IfAdminCreateDefaultOr(LoginPageServerError)} + onLoadError={LoginPageServerError} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> <Route path={InstancePaths.product_update} component={ProductUpdatePage} @@ -202,21 +203,21 @@ export function InstanceRoutes({ id, admin }: Props): VNode { * Order pages */} <Route path={InstancePaths.order_list} component={OrderListPage} - onCreate={() => {route(InstancePaths.order_new)}} + onCreate={() => { route(InstancePaths.order_new) }} onSelect={(id: string) => { route(InstancePaths.order_details.replace(':oid', id)) }} onUnauthorized={LoginPageAccessDenied} - onLoadError={IfAdminCreateDefaultOr(LoginPageServerError)} + onLoadError={LoginPageServerError} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> <Route path={InstancePaths.order_details} component={OrderDetailsPage} onUnauthorized={LoginPageAccessDenied} - onLoadError={IfAdminCreateDefaultOr(LoginPageServerError)} + onLoadError={LoginPageServerError} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} - onBack={() => {route(InstancePaths.order_list)}} + onBack={() => { route(InstancePaths.order_list) }} /> <Route path={InstancePaths.order_new} component={OrderCreatePage} - onConfirm={() => {route(InstancePaths.order_list)}} - onBack={() => {route(InstancePaths.order_list)}} + onConfirm={() => { route(InstancePaths.order_list) }} + onBack={() => { route(InstancePaths.order_list) }} /> {/** @@ -224,7 +225,7 @@ export function InstanceRoutes({ id, admin }: Props): VNode { */} <Route path={InstancePaths.transfers_list} component={TransferListPage} onUnauthorized={LoginPageAccessDenied} - onLoadError={IfAdminCreateDefaultOr(LoginPageServerError)} + onLoadError={LoginPageServerError} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> @@ -257,9 +258,9 @@ function AdminInstanceUpdatePage({ id, ...rest }: { id: string } & InstanceUpdat const i18n = useMessageTemplate(''); return <InstanceContextProvider value={value}> <InstanceUpdatePage {...rest} - onLoadError={(error: SwrError) => { + onLoadError={(error: HttpError) => { return <Fragment> - <NotificationCard notification={{ message: i18n`Problem reaching the server`, description: i18n`Got message: ${error.message} from: ${error.backend} (hasToken: ${error.hasToken})`, type: 'ERROR' }} /> + <NotificationCard notification={{ message: `Server reported a problem: HTTP status #${error.status}`, description: `Got message: ${error.message} from: ${error.info?.url}`, type: 'ERROR' }} /> <LoginPage onConfirm={updateLoginStatus} /> </Fragment> }} diff --git a/packages/frontend/src/declaration.d.ts b/packages/frontend/src/declaration.d.ts @@ -48,6 +48,45 @@ type UUID = string; type Integer = number; export namespace MerchantBackend { + interface ErrorDetail { + + // Numeric error code unique to the condition. + // The other arguments are specific to the error value reported here. + code: number; + + // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ... + // Should give a human-readable hint about the error's nature. Optional, may change without notice! + hint?: string; + + // Optional detail about the specific input value that failed. May change without notice! + detail?: string; + + // Name of the parameter that was bogus (if applicable). + parameter?: string; + + // Path to the argument that was bogus (if applicable). + path?: string; + + // Offset of the argument that was bogus (if applicable). + offset?: string; + + // Index of the argument that was bogus (if applicable). + index?: string; + + // Name of the object that was bogus (if applicable). + object?: string; + + // Name of the currency than was problematic (if applicable). + currency?: string; + + // Expected type (if applicable). + type_expected?: string; + + // Type that was provided instead (if applicable). + type_actual?: string; + } + + // Delivery location, loosely modeled as a subset of // ISO20022's PostalAddress25. interface Tax { diff --git a/packages/frontend/src/hooks/backend.ts b/packages/frontend/src/hooks/backend.ts @@ -19,14 +19,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import useSWR, { mutate, cache, useSWRInfinite } from 'swr'; -import axios from 'axios' +import { mutate, cache } from 'swr'; +import axios, { AxiosError } from 'axios' import { MerchantBackend } from '../declaration'; -import { useBackendContext, useInstanceContext } from '../context/backend'; -import { useEffect, useMemo, useState } from 'preact/hooks'; -import { MAX_RESULT_SIZE, PAGE_SIZE } from '../utils/constants'; -import { add, addHours, addSeconds, format, max } from 'date-fns'; -import { OrderAPI } from './order'; +import { useBackendContext } from '../context/backend'; +import { useEffect, useState } from 'preact/hooks'; export function mutateAll(re: RegExp) { cache.keys().filter(key => { @@ -35,38 +32,86 @@ export function mutateAll(re: RegExp) { }).forEach(key => mutate(key, null)) } -export type HttpResponse<T> = HttpResponseOk<T> | HttpResponseError; +export type HttpResponse<T> = HttpResponseOk<T> | HttpResponseLoading<T> | HttpError; +export type HttpResponsePaginated<T> = HttpResponseOkPaginated<T> | HttpResponseLoading<T> | HttpError; + +export interface RequestInfo { + url: string; + hasToken: boolean; + params: any; + data: any; +} + +interface HttpResponseLoading<T> { + ok?: false; + loading: true; + clientError?: false; + serverError?: false; + + data?: T; +} +export interface HttpResponseOk<T> { + ok: true; + loading?: false; + clientError?: false; + serverError?: false; -interface HttpResponseOk<T> { data: T; - unauthorized: boolean; - notfound: boolean; - isLoadingMore?: boolean; - loadMore?: () => void; - loadMorePrev?: () => void; + info?: RequestInfo; +} + +export type HttpResponseOkPaginated<T> = HttpResponseOk<T> & WithPagination + +export interface WithPagination { + loadMore: () => void; + loadMorePrev: () => void; isReachingEnd?: boolean; isReachingStart?: boolean; } +export type HttpError = HttpResponseClientError | HttpResponseServerError | HttpResponseUnexpectedError; export interface SwrError { info: any, status: number, message: string, - backend: string, - hasToken: boolean, -} -interface HttpResponseError { - data: undefined; - unauthorized: boolean; - notfound: boolean; - error?: SwrError; - isLoadingMore?: boolean; - loadMore?: () => void; - loadMorePrev?: () => void; - isReachingEnd?: boolean; - isReachingStart?: boolean; +} +export interface HttpResponseServerError { + ok?: false; + loading?: false; + clientError?: false; + serverError: true; + + error?: MerchantBackend.ErrorDetail; + status: number; + message: string; + info?: RequestInfo; +} +interface HttpResponseClientError { + ok?: false; + loading?: false; + clientError: true; + serverError?: false; + + info?: RequestInfo; + isUnauthorized: boolean; + isNotfound: boolean; + status: number; + error?: MerchantBackend.ErrorDetail; + message: string; + } +interface HttpResponseUnexpectedError { + ok?: false; + loading?: false; + clientError?: false; + serverError?: false; + + info?: RequestInfo; + status?: number; + error: any; + message: string; +} type Methods = 'get' | 'post' | 'patch' | 'delete' | 'put'; @@ -77,17 +122,76 @@ interface RequestOptions { params?: any; } +function buildRequestOk<T>(res: any, url: string, hasToken: boolean): HttpResponseOk<T> { + return { + ok: true, data: res.data, info: { + params: res.config.params, + data: res.config.data, + url, + hasToken, + } + } +} + +// function buildResponse<T>(data?: T, error?: MerchantBackend.ErrorDetail, isValidating?: boolean): HttpResponse<T> { +// if (isValidating) return {loading: true} +// if (error) return buildRequestFailed() +// } -export async function request(url: string, options: RequestOptions = {}): Promise<any> { +function buildRequestFailed(ex: AxiosError<MerchantBackend.ErrorDetail>, url: string, hasToken: boolean): HttpResponseClientError | HttpResponseServerError | HttpResponseUnexpectedError { + const status = ex.response?.status + + const info = { + data: ex.request?.data, + params: ex.request?.params, + url, + hasToken, + }; + + if (status && status >= 400 && status < 500) { + const error: HttpResponseClientError = { + clientError: true, + isNotfound: status === 404, + isUnauthorized: status === 401, + status, + info, + message: ex.response?.data?.hint || ex.message, + error: ex.response?.data + } + return error + } + if (status && status >= 500 && status < 600) { + const error: HttpResponseServerError = { + serverError: true, + status, + info, + message: ex.response?.data?.hint || ex.message, + error: ex.response?.data + } + return error; + } + + const error: HttpResponseUnexpectedError = { + info, + status, + error: ex, + message: ex.message + } + + return error +} + +export async function request<T>(url: string, options: RequestOptions = {}): Promise<HttpResponseOk<T>> { const headers = options.token ? { Authorization: `Bearer ${options.token}` } : undefined + try { - // http://localhost:9966/instances/blog/private/instances - // Hack, endpoint should respond 404 - if (/^\/instances\/[^/]*\/private\/instances$/.test(new URL(url).pathname)) { - console.warn(`HACK: Not going to query ${url}, instead return 404`) - throw ({ response: { status: 404 }, message: 'not found' }) - } + // // http://localhost:9966/instances/blog/private/instances + // // Hack, endpoint should respond 404 + // if (/^\/instances\/[^/]*\/private\/instances$/.test(new URL(url).pathname)) { + // console.warn(`HACK: Not going to query ${url}, instead return 404`) + // throw ({ response: { status: 404 }, message: 'not found' }) + // } const res = await axios({ @@ -98,59 +202,49 @@ export async function request(url: string, options: RequestOptions = {}): Promis data: options.data, params: options.params }) - return res.data + + return buildRequestOk<T>(res, url, !!options.token) } catch (e) { - console.error(e) - const info = e.response?.data - const status = e.response?.status - const hint = info?.hint - throw { info, status, message: hint || e.message, backend: url, hasToken: !!options.token } + const error = buildRequestFailed(e, url, !!options.token) + throw error } } -export function fetcher(url: string, token: string, backend: string) { - return request(`${backend}${url}`, { token }) +export function fetcher<T>(url: string, token: string, backend: string): Promise<HttpResponseOk<T>> { + return request<T>(`${backend}${url}`, { token }) } export function useBackendInstancesTestForAdmin(): HttpResponse<MerchantBackend.Instances.InstancesResponse> { const { url, token } = useBackendContext() - interface Result { - data?: MerchantBackend.Instances.InstancesResponse; - error?: SwrError; - } - const [result, setResult] = useState<Result | undefined>(undefined) + + type Type = MerchantBackend.Instances.InstancesResponse; + + const [result, setResult] = useState<HttpResponse<Type>>({ loading: true }) useEffect(() => { - request(`${url}/private/instances`, { token }) - .then(data => setResult({ data })) - .catch(error => setResult({ error })) + request<Type>(`${url}/private/instances`, { token }) + .then(data => setResult(data)) + .catch(error => setResult(error)) }, [url, token]) - const data = result?.data - const error = result?.error - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + return result } export function useBackendConfig(): HttpResponse<MerchantBackend.VersionResponse> { const { url, token } = useBackendContext() - interface Result { - data?: MerchantBackend.VersionResponse; - error?: SwrError; - } - const [result, setResult] = useState<Result | undefined>(undefined) + type Type = MerchantBackend.VersionResponse; + + const [result, setResult] = useState<HttpResponse<Type>>({ loading: true }) useEffect(() => { - request(`${url}/config`, { token }) - .then(data => setResult({ data })) - .catch(error => setResult({ error })) + request<Type>(`${url}/config`, { token }) + .then(data => setResult(data)) + .catch(error => setResult(error)) }, [url, token]) - const data = result?.data - const error = result?.error - - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + return result } diff --git a/packages/frontend/src/hooks/instance.ts b/packages/frontend/src/hooks/instance.ts @@ -1,6 +1,6 @@ import { MerchantBackend } from '../declaration'; import { useBackendContext, useInstanceContext } from '../context/backend'; -import { fetcher, HttpResponse, request, SwrError } from './backend'; +import { fetcher, HttpError, HttpResponse, HttpResponseOk, request, SwrError } from './backend'; import useSWR, { mutate } from 'swr'; @@ -69,9 +69,6 @@ export function useInstanceAPI(): InstanceAPI { export function useInstanceDetails(): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> { - // const { url: baseUrl } = useBackendContext(); - // const { token, id, admin } = useInstanceContext(); - // const url = !admin ? baseUrl : `${baseUrl}/instances/${id}` const { url: baseUrl, token: baseToken } = useBackendContext(); const { token: instanceToken, id, admin } = useInstanceContext(); @@ -81,17 +78,23 @@ export function useInstanceDetails(): HttpResponse<MerchantBackend.Instances.Que url: `${baseUrl}/instances/${id}`, token: instanceToken } - const { data, error } = useSWR<MerchantBackend.Instances.QueryInstancesResponse, SwrError>([`/private/`, token, url], fetcher) + const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>, HttpError>([`/private/`, token, url], fetcher) - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + if (isValidating) return {loading:true, data: data?.data} + if (data) return data + if (error) return error + return {loading: true} } export function useBackendInstances(): HttpResponse<MerchantBackend.Instances.InstancesResponse> { const { url } = useBackendContext() const { token } = useInstanceContext(); - const { data, error } = useSWR<MerchantBackend.Instances.InstancesResponse, SwrError>(['/private/instances', token, url], fetcher) + const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Instances.InstancesResponse>, HttpError>(['/private/instances', token, url], fetcher) - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + if (isValidating) return {loading:true, data: data?.data} + if (data) return data + if (error) return error + return {loading: true} } diff --git a/packages/frontend/src/hooks/order.ts b/packages/frontend/src/hooks/order.ts @@ -4,26 +4,23 @@ import useSWR from 'swr'; import { useBackendContext, useInstanceContext } from '../context/backend'; import { MerchantBackend } from '../declaration'; import { MAX_RESULT_SIZE, PAGE_SIZE } from '../utils/constants'; -import { fetcher, HttpResponse, mutateAll, request, SwrError } from './backend'; +import { fetcher, HttpError, HttpResponse, HttpResponseOk, HttpResponsePaginated, mutateAll, request, SwrError, WithPagination } from './backend'; export interface OrderAPI { //FIXME: add OutOfStockResponse on 410 - createOrder: (data: MerchantBackend.Orders.PostOrderRequest) => Promise<MerchantBackend.Orders.PostOrderResponse>; - forgetOrder: (id: string, data: MerchantBackend.Orders.ForgetRequest) => Promise<void>; - refundOrder: (id: string, data: MerchantBackend.Orders.RefundRequest) => Promise<MerchantBackend.Orders.MerchantRefundResponse>; - deleteOrder: (id: string) => Promise<void>; - getPaymentURL: (id: string) => Promise<string>; + createOrder: (data: MerchantBackend.Orders.PostOrderRequest) => Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>>; + forgetOrder: (id: string, data: MerchantBackend.Orders.ForgetRequest) => Promise<HttpResponseOk<void>>; + refundOrder: (id: string, data: MerchantBackend.Orders.RefundRequest) => Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>>; + deleteOrder: (id: string) => Promise<HttpResponseOk<void>>; + getPaymentURL: (id: string) => Promise<HttpResponseOk<string>>; } type YesOrNo = 'yes' | 'no'; -export function orderFetcher(url: string, token: string, backend: string, paid?: YesOrNo, refunded?: YesOrNo, wired?: YesOrNo, searchDate?: Date, delta?: number) { - const newDate = searchDate && addHours(searchDate, 3) // remove this, locale - // if we are - const newDatePlus1SecIfNeeded = delta && delta < 0 && newDate ? addSeconds(newDate, 1) : newDate - const date = newDatePlus1SecIfNeeded ? format(newDatePlus1SecIfNeeded, 'yyyy-MM-dd HH:mm:ss') : undefined - return request(`${backend}${url}`, { token, params: { paid, refunded, wired, delta, date } }) +export function orderFetcher<T>(url: string, token: string, backend: string, paid?: YesOrNo, refunded?: YesOrNo, wired?: YesOrNo, searchDate?: Date, delta?: number): Promise<HttpResponseOk<T>> { + const date_ms = delta && delta < 0 && searchDate ? searchDate.getTime() + 1 : searchDate?.getTime() + return request<T>(`${backend}${url}`, { token, params: { paid, refunded, wired, delta, date_ms } }) } @@ -37,65 +34,70 @@ export function useOrderAPI(): OrderAPI { url: `${baseUrl}/instances/${id}`, token: instanceToken } - const createOrder = async (data: MerchantBackend.Orders.PostOrderRequest): Promise<MerchantBackend.Orders.PostOrderResponse> => { - const res = await request(`${url}/private/orders`, { + const createOrder = async (data: MerchantBackend.Orders.PostOrderRequest): Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>> => { + mutateAll(/@"\/private\/orders"@/) + return request<MerchantBackend.Orders.PostOrderResponse>(`${url}/private/orders`, { method: 'post', token, data }) - - mutateAll(/@"\/private\/orders"@/) - return res + // return res } - const refundOrder = async (orderId: string, data: MerchantBackend.Orders.RefundRequest): Promise<MerchantBackend.Orders.MerchantRefundResponse> => { - const res = await request(`${url}/private/orders/${orderId}/refund`, { + const refundOrder = async (orderId: string, data: MerchantBackend.Orders.RefundRequest): Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>> => { + mutateAll(/@"\/private\/orders"@/) + return request<MerchantBackend.Orders.MerchantRefundResponse>(`${url}/private/orders/${orderId}/refund`, { method: 'post', token, data }) - mutateAll(/@"\/private\/orders"@/) - return res + // return res } - const forgetOrder = async (orderId: string, data: MerchantBackend.Orders.ForgetRequest): Promise<void> => { - await request(`${url}/private/orders/${orderId}/forget`, { + const forgetOrder = async (orderId: string, data: MerchantBackend.Orders.ForgetRequest): Promise<HttpResponseOk<void>> => { + mutateAll(/@"\/private\/orders"@/) + return request(`${url}/private/orders/${orderId}/forget`, { method: 'patch', token, data }) - mutateAll(/@"\/private\/orders"@/) } - const deleteOrder = async (orderId: string): Promise<void> => { - await request(`${url}/private/orders/${orderId}`, { + const deleteOrder = async (orderId: string): Promise<HttpResponseOk<void>> => { + mutateAll(/@"\/private\/orders"@/) + return request(`${url}/private/orders/${orderId}`, { method: 'delete', token }) - - mutateAll(/@"\/private\/orders"@/) } - const getPaymentURL = async (orderId: string): Promise<string> => { - const data = await request(`${url}/private/orders/${orderId}`, { + const getPaymentURL = async (orderId: string): Promise<HttpResponseOk<string>> => { + return request<MerchantBackend.Orders.MerchantOrderStatusResponse>(`${url}/private/orders/${orderId}`, { method: 'get', token + }).then((res) => { + const url = res.data.order_status === "unpaid" ? res.data.taler_pay_uri : res.data.contract_terms.fulfillment_url + const response: HttpResponseOk<string> = res as any + response.data = url || '' + return response }) - return data.taler_pay_uri || data.contract_terms?.fulfillment_url } return { createOrder, forgetOrder, deleteOrder, refundOrder, getPaymentURL } } -export function useOrderDetails(oderId:string): HttpResponse<MerchantBackend.Orders.MerchantOrderStatusResponse> { +export function useOrderDetails(oderId: string): HttpResponse<MerchantBackend.Orders.MerchantOrderStatusResponse> { const { url: baseUrl } = useBackendContext(); const { token, id: instanceId, admin } = useInstanceContext(); const url = !admin ? baseUrl : `${baseUrl}/instances/${instanceId}` - const { data, error } = useSWR<MerchantBackend.Orders.MerchantOrderStatusResponse, SwrError>([`/private/orders/${oderId}`, token, url], fetcher) + const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Orders.MerchantOrderStatusResponse>, HttpError>([`/private/orders/${oderId}`, token, url], fetcher) - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + if (isValidating) return { loading: true, data: data?.data } + if (data) return data + if (error) return error + return { loading: true } } export interface InstanceOrderFilter { @@ -105,7 +107,7 @@ export interface InstanceOrderFilter { date?: Date; } -export function useInstanceOrders(args: InstanceOrderFilter, updateFilter: (d:Date)=>void): HttpResponse<MerchantBackend.Orders.OrderHistory> { +export function useInstanceOrders(args: InstanceOrderFilter, updateFilter: (d: Date) => void): HttpResponsePaginated<MerchantBackend.Orders.OrderHistory> { const { url: baseUrl, token: baseToken } = useBackendContext(); const { token: instanceToken, id, admin } = useInstanceContext(); @@ -127,51 +129,58 @@ export function useInstanceOrders(args: InstanceOrderFilter, updateFilter: (d:Da * the logic of double query should be inside the orderFetch so from the hook perspective and cache * is just one query and one error status */ - const { data:beforeData, error:beforeError } = useSWR<MerchantBackend.Orders.OrderHistory, SwrError>( + const { data: beforeData, error: beforeError, isValidating: loadingBefore } = useSWR<HttpResponseOk<MerchantBackend.Orders.OrderHistory>, HttpError>( [`/private/orders`, token, url, args?.paid, args?.refunded, args?.wired, args?.date, totalBefore], orderFetcher, ) - const { data:afterData, error:afterError } = useSWR<MerchantBackend.Orders.OrderHistory, SwrError>( + const { data: afterData, error: afterError, isValidating: loadingAfter } = useSWR<HttpResponseOk<MerchantBackend.Orders.OrderHistory>, HttpError>( [`/private/orders`, token, url, args?.paid, args?.refunded, args?.wired, args?.date, -totalAfter], orderFetcher, ) //this will save last result - const [lastBefore, setLastBefore] = useState<MerchantBackend.Orders.OrderHistory | undefined>(undefined) - const [lastAfter, setLastAfter] = useState<MerchantBackend.Orders.OrderHistory | undefined>(undefined) + const [lastBefore, setLastBefore] = useState<HttpResponse<MerchantBackend.Orders.OrderHistory>>({ loading: true }) + const [lastAfter, setLastAfter] = useState<HttpResponse<MerchantBackend.Orders.OrderHistory>>({ loading: true }) useEffect(() => { if (afterData) setLastAfter(afterData) if (beforeData) setLastBefore(beforeData) }, [afterData, beforeData]) // this has problems when there are some ids missing - const isReachingEnd = afterData && afterData.orders.length < totalAfter; - const isReachingStart = (!args?.date) || (beforeData && beforeData.orders.length < totalBefore); - - const orders = !beforeData || !afterData ? undefined : (beforeData || lastBefore).orders.slice().reverse().concat((afterData || lastAfter).orders) - const unauthorized = beforeError?.status === 401 || afterError?.status === 401 - const notfound = beforeError?.status === 404 || afterError?.status === 404 - - const loadMore = () => { - if (!orders) return - if (orders.length < MAX_RESULT_SIZE) { - setPageAfter(pageAfter + 1) - } else { - const from = afterData?.orders?.[afterData?.orders?.length-1]?.timestamp?.t_ms - if (from) updateFilter(new Date(from)) - } - } - const loadMorePrev = () => { - if (!orders) return - if (orders.length < MAX_RESULT_SIZE) { - setPageBefore(pageBefore + 1) - } else { - const from = beforeData?.orders?.[beforeData?.orders?.length-1]?.timestamp?.t_ms - if (from) updateFilter(new Date(from)) - } + if (beforeError) return beforeError + if (afterError) return afterError + + + const pagination = { + isReachingEnd: afterData && afterData.data.orders.length < totalAfter, + isReachingStart: (!args?.date) || (beforeData && beforeData.data.orders.length < totalBefore), + loadMore: () => { + if (!afterData) return + if (afterData.data.orders.length < MAX_RESULT_SIZE) { + setPageAfter(pageAfter + 1) + } else { + const from = afterData.data.orders[afterData.data.orders.length - 1].timestamp.t_ms + if (from) updateFilter(new Date(from)) + } + }, + loadMorePrev: () => { + if (!beforeData) return + if (beforeData.data.orders.length < MAX_RESULT_SIZE) { + setPageBefore(pageBefore + 1) + } else if (beforeData) { + const from = beforeData.data.orders[beforeData.data.orders.length - 1].timestamp.t_ms + if (from) updateFilter(new Date(from)) + } + }, + } + + const orders = !beforeData || !afterData ? [] : (beforeData || lastBefore).data.orders.slice().reverse().concat((afterData || lastAfter).data.orders) + if (loadingAfter || loadingBefore) return { loading: true, data: { orders: orders } } + if (beforeData && afterData) { + return { ok: true, data: { orders: orders }, ...pagination } } + return { loading: true } - return { data: orders ? {orders} : undefined, loadMorePrev, loadMore, isReachingEnd, isReachingStart, unauthorized, notfound, error: beforeError ? beforeError : afterError } } diff --git a/packages/frontend/src/hooks/product.ts b/packages/frontend/src/hooks/product.ts @@ -2,7 +2,7 @@ import { useEffect } from 'preact/hooks'; import useSWR, { useSWRInfinite } from 'swr'; import { useBackendContext, useInstanceContext } from '../context/backend'; import { MerchantBackend } from '../declaration'; -import { fetcher, HttpResponse, mutateAll, request, SwrError } from './backend'; +import { fetcher, HttpError, HttpResponse, HttpResponseOk, mutateAll, request, SwrError } from './backend'; export interface ProductAPI { @@ -77,29 +77,34 @@ export function useInstanceProducts(): HttpResponse<(MerchantBackend.Products.Pr url: `${baseUrl}/instances/${id}`, token: instanceToken }; - const list = useSWR<MerchantBackend.Products.InventorySummaryResponse, SwrError>([`/private/products`, token, url], fetcher); + const { data: list, error: listError, isValidating: listLoading } = useSWR<HttpResponseOk<MerchantBackend.Products.InventorySummaryResponse>, HttpError>([`/private/products`, token, url], fetcher); - const getKey = (pageIndex: number) => { - if (!list.data || !list.data.products.length) return null + const { data: products, error: productError, setSize } = useSWRInfinite<HttpResponseOk<MerchantBackend.Products.ProductDetail>, HttpError>((pageIndex: number) => { + if (!list?.data || !list.data.products.length || listError) return null return [`/private/products/${list.data.products[pageIndex].product_id}`, token, url] - } - - const res = useSWRInfinite<MerchantBackend.Products.ProductDetail, SwrError>(getKey, fetcher) - const { data, error, setSize, isValidating } = res + }, fetcher) useEffect(() => { - if (list.data && list.data?.products?.length > 0) { - setSize(list.data?.products?.length) + if (list?.data && list.data.products.length > 0) { + setSize(list.data.products.length) } - }, [list.data]) + }, [list?.data]) - if (list.data && list.data.products.length === 0) { - return { data: [], unauthorized: false, notfound: false } - } + if (listLoading) return { loading: true, data: [] } + if (listError) return listError + if (productError) return productError + if (list?.data && list.data.products.length === 0) { + return { ok: true, data: [] } + } + if (products) { + const dataWithId = products.map((d, i) => { + //take the id from the queried url + return ({ ...d.data, id: d.info?.url.replace(/.*\/private\/products\//, '') || '' }) + }) + return { ok: true, data: dataWithId } + } - const dataWithId = !error ? data?.map((d, i) => ({ ...d, id: list.data?.products?.[i]?.product_id || '' })) : undefined - - return { data: dataWithId, unauthorized: error?.status === 401, notfound: error?.status === 404, error }; + return { loading: true } } diff --git a/packages/frontend/src/hooks/tips.ts b/packages/frontend/src/hooks/tips.ts @@ -1,6 +1,6 @@ import { MerchantBackend } from '../declaration'; import { useBackendContext, useInstanceContext } from '../context/backend'; -import { request, mutateAll, HttpResponse, SwrError, fetcher } from './backend'; +import { request, mutateAll, HttpResponse, SwrError, fetcher, HttpError, HttpResponseOk } from './backend'; import useSWR from 'swr'; @@ -14,47 +14,42 @@ export function useTipsMutateAPI(): TipsMutateAPI { url: `${baseUrl}/instances/${id}`, token: instanceToken }; - //reserves - const createReserve = async (data: MerchantBackend.Tips.ReserveCreateRequest): Promise<MerchantBackend.Tips.ReserveCreateConfirmation> => { - const res = await request(`${url}/private/reserves`, { + const createReserve = async (data: MerchantBackend.Tips.ReserveCreateRequest): Promise<HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation>> => { + mutateAll(/@"\/private\/reserves"@/); + + return request<MerchantBackend.Tips.ReserveCreateConfirmation>(`${url}/private/reserves`, { method: 'post', token, data }); + }; + const authorizeTipReserve = (pub: string, data: MerchantBackend.Tips.TipCreateRequest): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => { mutateAll(/@"\/private\/reserves"@/); - return res; - }; - const authorizeTipReserve = async (pub: string, data: MerchantBackend.Tips.TipCreateRequest): Promise<MerchantBackend.Tips.TipCreateConfirmation> => { - const res = await request(`${url}/private/reserves/${pub}/authorize-tip`, { + return request<MerchantBackend.Tips.TipCreateConfirmation>(`${url}/private/reserves/${pub}/authorize-tip`, { method: 'post', token, data }); + }; + const authorizeTip = (data: MerchantBackend.Tips.TipCreateRequest): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => { mutateAll(/@"\/private\/reserves"@/); - return res; - }; - const authorizeTip = async (data: MerchantBackend.Tips.TipCreateRequest): Promise<MerchantBackend.Tips.TipCreateConfirmation> => { - const res = await request(`${url}/private/tips`, { + return request<MerchantBackend.Tips.TipCreateConfirmation>(`${url}/private/tips`, { method: 'post', token, data }); - - mutateAll(/@"\/private\/reserves"@/); - return res; }; - const deleteReserve = async (pub: string): Promise<void> => { - await request(`${url}/private/reserves/${pub}`, { + const deleteReserve = (pub: string): Promise<HttpResponse<void>> => { + mutateAll(/@"\/private\/reserves"@/); + return request(`${url}/private/reserves/${pub}`, { method: 'delete', token, }); - - mutateAll(/@"\/private\/reserves"@/); }; @@ -62,13 +57,12 @@ export function useTipsMutateAPI(): TipsMutateAPI { } export interface TipsMutateAPI { - createReserve: (data: MerchantBackend.Tips.ReserveCreateRequest) => Promise<MerchantBackend.Tips.ReserveCreateConfirmation>; - authorizeTipReserve: (id: string, data: MerchantBackend.Tips.TipCreateRequest) => Promise<MerchantBackend.Tips.TipCreateConfirmation>; - authorizeTip: (data: MerchantBackend.Tips.TipCreateRequest) => Promise<MerchantBackend.Tips.TipCreateConfirmation>; - deleteReserve: (id: string) => Promise<void>; + createReserve: (data: MerchantBackend.Tips.ReserveCreateRequest) => Promise<HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation>>; + authorizeTipReserve: (id: string, data: MerchantBackend.Tips.TipCreateRequest) => Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>>; + authorizeTip: (data: MerchantBackend.Tips.TipCreateRequest) => Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>>; + deleteReserve: (id: string) => Promise<HttpResponse<void>>; } - export function useInstanceTips(): HttpResponse<MerchantBackend.Tips.TippingReserveStatus> { const { url: baseUrl, token: baseToken } = useBackendContext(); const { token: instanceToken, id, admin } = useInstanceContext(); @@ -79,8 +73,11 @@ export function useInstanceTips(): HttpResponse<MerchantBackend.Tips.TippingRese url: `${baseUrl}/instances/${id}`, token: instanceToken } - const { data, error } = useSWR<MerchantBackend.Tips.TippingReserveStatus, SwrError>([`/private/reserves`, token, url], fetcher) + const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Tips.TippingReserveStatus>, HttpError>([`/private/reserves`, token, url], fetcher) - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + if (isValidating) return {loading:true, data: data?.data} + if (data) return data + if (error) return error + return {loading: true} } diff --git a/packages/frontend/src/hooks/transfer.ts b/packages/frontend/src/hooks/transfer.ts @@ -1,13 +1,12 @@ import { MerchantBackend } from '../declaration'; import { useBackendContext, useInstanceContext } from '../context/backend'; -import { request, mutateAll, HttpResponse, SwrError } from './backend'; +import { request, mutateAll, HttpResponse, HttpError, HttpResponseOk } from './backend'; import useSWR from 'swr'; -function transferFetcher(url: string, token: string, backend: string) { - return request(`${backend}${url}`, { token, params: { payto_uri: '' } }) +async function transferFetcher<T>(url: string, token: string, backend: string): Promise<HttpResponseOk<T>> { + return request<T>(`${backend}${url}`, { token, params: { payto_uri: '' } }) } - export function useTransferMutateAPI(): TransferMutateAPI { const { url: baseUrl, token: adminToken } = useBackendContext(); const { token: instanceToken, id, admin } = useInstanceContext(); @@ -18,22 +17,21 @@ export function useTransferMutateAPI(): TransferMutateAPI { url: `${baseUrl}/instances/${id}`, token: instanceToken }; - const informTransfer = async (data: MerchantBackend.Transfers.TransferInformation): Promise<MerchantBackend.Transfers.MerchantTrackTransferResponse> => { - const res = await request(`${url}/private/transfers`, { + const informTransfer = async (data: MerchantBackend.Transfers.TransferInformation): Promise<HttpResponseOk<MerchantBackend.Transfers.MerchantTrackTransferResponse>> => { + mutateAll(/@"\/private\/transfers"@/); + + return request<MerchantBackend.Transfers.MerchantTrackTransferResponse>(`${url}/private/transfers`, { method: 'post', token, data }); - - mutateAll(/@"\/private\/transfers"@/); - return res; }; return { informTransfer }; } export interface TransferMutateAPI { - informTransfer: (data: MerchantBackend.Transfers.TransferInformation) => Promise<MerchantBackend.Transfers.MerchantTrackTransferResponse>; + informTransfer: (data: MerchantBackend.Transfers.TransferInformation) => Promise<HttpResponseOk<MerchantBackend.Transfers.MerchantTrackTransferResponse>>; } export function useInstanceTransfers(): HttpResponse<MerchantBackend.Transfers.TransferList> { @@ -46,9 +44,12 @@ export function useInstanceTransfers(): HttpResponse<MerchantBackend.Transfers.T url: `${baseUrl}/instances/${id}`, token: instanceToken } - const { data, error } = useSWR<MerchantBackend.Transfers.TransferList, SwrError>([`/private/transfers`, token, url], transferFetcher) + const { data, error, isValidating } = useSWR<HttpResponseOk<MerchantBackend.Transfers.TransferList>, HttpError>([`/private/transfers`, token, url], transferFetcher) - return { data, unauthorized: error?.status === 401, notfound: error?.status === 404, error } + if (isValidating) return {loading:true, data: data?.data} + if (data) return data + if (error) return error + return {loading: true} } diff --git a/packages/frontend/src/index.tsx b/packages/frontend/src/index.tsx @@ -22,7 +22,7 @@ import "./scss/main.scss" import { h, VNode } from 'preact'; -import { useEffect, useMemo } from "preact/hooks"; +import { useMemo } from "preact/hooks"; import { route } from 'preact-router'; import { MessageProvider, useMessageTemplate } from 'preact-messages'; @@ -35,6 +35,7 @@ import { hasKey, onTranslationError } from "./utils/functions"; import LoginPage from './paths/login'; import { ApplicationReadyRoutes } from "./ApplicationReadyRoutes"; import { NotificationCard, NotYetReadyAppMenu } from "./components/menu"; +import { Loading } from "./components/exception/loading"; export default function Application(): VNode { const state = useBackendContextState() @@ -50,12 +51,9 @@ export default function Application(): VNode { function ApplicationStatusRoutes(): VNode { const { changeBackend, triedToLog, updateToken, resetBackend } = useBackendContext() - const backendConfig = useBackendConfig(); + const result = useBackendConfig(); const i18n = useMessageTemplate() - const v = `${backendConfig.data?.currency} ${backendConfig.data?.version}` - const ctx = useMemo(() => ({ currency: backendConfig.data?.currency || '', version: backendConfig.data?.version || '' }), [v]) - const updateLoginInfoAndGoToRoot = (url: string, token?: string) => { changeBackend(url) if (token) updateToken(token) @@ -69,27 +67,44 @@ function ApplicationStatusRoutes(): VNode { </div> } - if (!backendConfig.data) { + if (result.clientError && result.isUnauthorized) return <div id="app"> + <NotYetReadyAppMenu title="Login" /> + <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> + </div> - if (!backendConfig.error) return <div class="is-loading" /> + if (result.clientError && result.isNotfound) return <div id="app"> + <NotYetReadyAppMenu title="Error" /> + <NotificationCard notification={{ + message: i18n`Server not found`, + type: 'ERROR', + description: `Check your url`, + }} /> + <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> + </div> - if (backendConfig.unauthorized) { - return <div id="app"> - <NotYetReadyAppMenu title="Login" /> - <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> - </div> - } + if (result.serverError) return <div id="app"> + <NotYetReadyAppMenu title="Error" /> + <NotificationCard notification={{ + message: i18n`Couldn't access the server`, + type: 'ERROR', + description: i18n`Got message ${result.message} from ${result.info?.url}`, + }} /> + <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> + </div> - return <div id="app"> - <NotYetReadyAppMenu title="Error" /> - <NotificationCard notification={{ - message: i18n`Couldnt access the server`, - type: 'ERROR', - description: i18n`Got message: ${backendConfig.error.message} from: ${backendConfig.error.backend} (hasToken: ${backendConfig.error.hasToken})`, - }} /> - <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> - </div> - } + if (result.loading) return <Loading /> + + if (!result.ok) return <div id="app"> + <NotYetReadyAppMenu title="Error" /> + <NotificationCard notification={{ + message: i18n`Unexpected Error`, + type: 'ERROR', + description: i18n`Got message ${result.error.message} from ${result.info?.url}`, + }} /> + <LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> + </div> + + const ctx = useMemo(() => ({ currency: result.data.currency, version: result.data.version }), [result.data.currency, result.data.version]) return <div id="app" class="has-navbar-fixed-top"> <ConfigContextProvider value={ctx}> diff --git a/packages/frontend/src/messages/en.po b/packages/frontend/src/messages/en.po @@ -145,10 +145,13 @@ msgstr "Pay delay" msgid "fields.instance.default_wire_transfer_delay.label" msgstr "Wire transfer delay" -msgid "Couldnt access the server" -msgstr "Couldnt access the server" +msgid "Couldn't access the server" +msgstr "Couldn't access the server" -msgid "Got message: %s from: %s (hasToken: %s)" +msgid "Unexpected Error" +msgstr "Unexpected Error" + +msgid "Got message %s from %s" msgstr "Got message \"%s\" from %s" msgid "Merchant" diff --git a/packages/frontend/src/paths/admin/create/CreatePage.tsx b/packages/frontend/src/paths/admin/create/CreatePage.tsx @@ -51,9 +51,9 @@ interface KeyValue { function with_defaults(id?: string): Partial<Entity> { return { id, - default_pay_delay: { d_ms: 1000 }, - default_wire_fee_amortization: 10, - default_wire_transfer_delay: { d_ms: 2000 }, + default_pay_delay: { d_ms: 1000*60*5 }, + default_wire_fee_amortization: 1, + default_wire_transfer_delay: { d_ms: 2000*60*5 }, }; } diff --git a/packages/frontend/src/paths/admin/create/index.tsx b/packages/frontend/src/paths/admin/create/index.tsx @@ -22,6 +22,7 @@ import { useState } from "preact/hooks"; import { NotificationCard } from "../../../components/menu"; import { MerchantBackend } from "../../../declaration"; import { useAdminAPI } from "../../../hooks/admin"; +import { RequestInfo } from "../../../hooks/backend"; import { Notification } from "../../../utils/types"; import { CreatePage } from "./CreatePage"; import { InstanceCreatedSuccessfully } from "./InstanceCreatedSuccessfully"; @@ -29,12 +30,11 @@ import { InstanceCreatedSuccessfully } from "./InstanceCreatedSuccessfully"; interface Props { onBack?: () => void; onConfirm: () => void; - onError: (error: any) => void; forceId?: string; } export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage; -export default function Create({ onBack, onConfirm, onError, forceId }: Props): VNode { +export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const { createInstance } = useAdminAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined) const [createdOk, setCreatedOk] = useState<Entity | undefined>(undefined); diff --git a/packages/frontend/src/paths/admin/list/index.tsx b/packages/frontend/src/paths/admin/list/index.tsx @@ -21,7 +21,7 @@ import { Fragment, h, VNode } from 'preact'; import { View } from './View'; -import { SwrError } from '../../../hooks/backend'; +import { HttpError, HttpResponseServerError, RequestInfo, SwrError } from '../../../hooks/backend'; import { useAdminAPI } from "../../../hooks/admin"; import { useState } from 'preact/hooks'; import { MerchantBackend } from '../../../declaration'; @@ -31,24 +31,23 @@ import { Loading } from '../../../components/exception/loading'; import { useBackendInstances } from '../../../hooks/instance'; interface Props { - // pushNotification: (n: Notification) => void; onCreate: () => void; onUpdate: (id: string) => void; instances: MerchantBackend.Instances.Instance[]; onUnauthorized: () => VNode; - onLoadError: (e: SwrError) => VNode; + onNotFound: () => VNode; + onLoadError: (error: HttpError) => VNode; } -export default function Instances({ onUnauthorized, onLoadError, onCreate, onUpdate }: Props): VNode { +export default function Instances({ onUnauthorized, onLoadError, onNotFound, onCreate, onUpdate }: Props): VNode { const result = useBackendInstances() const [deleting, setDeleting] = useState<MerchantBackend.Instances.Instance | null>(null) const { deleteInstance } = useAdminAPI() - if (result.unauthorized) return onUnauthorized() - if (!result.data) { - if (result.error) return onLoadError(result.error) - return <Loading /> - } + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) return <Fragment> <View instances={result.data.instances} diff --git a/packages/frontend/src/paths/instance/details/index.tsx b/packages/frontend/src/paths/instance/details/index.tsx @@ -16,15 +16,16 @@ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useInstanceContext } from "../../../context/backend"; -import { SwrError } from "../../../hooks/backend"; +import { HttpError, HttpResponseServerError, RequestInfo, SwrError } from "../../../hooks/backend"; import { DetailPage } from "./DetailPage"; import { DeleteModal } from "../../../components/modal"; import { Loading } from "../../../components/exception/loading"; import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance"; +import { MerchantBackend } from "../../../declaration"; interface Props { onUnauthorized: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (error: HttpError) => VNode; onUpdate: () => void; onNotFound: () => VNode; onDelete: () => void; @@ -37,12 +38,10 @@ export default function Detail({ onUpdate, onLoadError, onUnauthorized, onDelete const { deleteInstance } = useInstanceAPI() - if (result.unauthorized) return onUnauthorized() - if (result.notfound) return onNotFound(); - if (!result.data) { - if (result.error) return onLoadError(result.error) - return <Loading /> - } + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) return <Fragment> <DetailPage diff --git a/packages/frontend/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx b/packages/frontend/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx @@ -30,8 +30,8 @@ export function OrderCreatedSuccessfully({ entity, onConfirm, onCreateAnother }: const [url, setURL] = useState<string | undefined>(undefined) useEffect(() => { - getPaymentURL(entity.response.order_id).then(url => { - setURL(url) + getPaymentURL(entity.response.order_id).then(response => { + setURL(response.data) }) },[entity.response.order_id]) diff --git a/packages/frontend/src/paths/instance/orders/create/index.tsx b/packages/frontend/src/paths/instance/orders/create/index.tsx @@ -58,7 +58,7 @@ export default function ({ onConfirm, onBack }: Props): VNode { onBack={onBack} onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => { createOrder(request).then((response) => { - setCreatedOk({ request, response }) + setCreatedOk({ request, response: response.data }) }).catch((error) => { setNotif({ message: 'could not create order', diff --git a/packages/frontend/src/paths/instance/orders/details/index.tsx b/packages/frontend/src/paths/instance/orders/details/index.tsx @@ -16,7 +16,9 @@ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { Loading } from "../../../../components/exception/loading"; -import { SwrError } from "../../../../hooks/backend"; +import { NotificationCard } from "../../../../components/menu"; +import { MerchantBackend } from "../../../../declaration"; +import { HttpError, HttpResponseServerError, RequestInfo } from "../../../../hooks/backend"; import { useOrderDetails, useOrderAPI } from "../../../../hooks/order"; import { Notification } from "../../../../utils/types"; import { DetailPage } from "./DetailPage"; @@ -27,23 +29,23 @@ export interface Props { onBack: () => void; onUnauthorized: () => VNode; onNotFound: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (error: HttpError) => VNode; } export default function Update({ oid, onBack, onLoadError, onNotFound, onUnauthorized }: Props): VNode { const { refundOrder } = useOrderAPI(); - const details = useOrderDetails(oid) + const result = useOrderDetails(oid) const [notif, setNotif] = useState<Notification | undefined>(undefined) - if (details.unauthorized) return onUnauthorized() - if (details.notfound) return onNotFound(); - - if (!details.data) { - if (details.error) return onLoadError(details.error) - return <Loading /> - } + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) return <Fragment> + + <NotificationCard notification={notif} /> + <DetailPage onBack={onBack} id={oid} @@ -57,7 +59,7 @@ export default function Update({ oid, onBack, onLoadError, onNotFound, onUnautho description: error.message })) } - selected={details.data} + selected={result.data} /> </Fragment> } \ No newline at end of file diff --git a/packages/frontend/src/paths/instance/orders/list/index.tsx b/packages/frontend/src/paths/instance/orders/list/index.tsx @@ -22,7 +22,7 @@ import { h, VNode } from 'preact'; import { useState } from 'preact/hooks'; import { MerchantBackend } from '../../../../declaration'; -import { SwrError } from '../../../../hooks/backend'; +import { HttpError, HttpResponseServerError, RequestInfo, SwrError } from '../../../../hooks/backend'; import { CardTable } from './Table'; import { format } from 'date-fns'; import { DatePicker } from '../../../../components/form/DatePicker'; @@ -30,10 +30,11 @@ import { NotificationCard } from '../../../../components/menu'; import { Notification } from '../../../../utils/types'; import { copyToClipboard } from '../../../../utils/functions'; import { InstanceOrderFilter, useInstanceOrders, useOrderAPI } from '../../../../hooks/order'; +import { Loading } from '../../../../components/exception/loading'; interface Props { onUnauthorized: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (error: HttpError) => VNode; onNotFound: () => VNode; onSelect: (id: string) => void; onCreate: () => void; @@ -47,24 +48,14 @@ export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNo const setNewDate = (date: Date) => setFilter(prev => ({ ...prev, date })) const result = useInstanceOrders(filter, setNewDate) - const { createOrder, refundOrder, getPaymentURL } = useOrderAPI() - // const { currency } = useConfigContext() - - let instances: (MerchantBackend.Orders.OrderHistoryEntry & { id: string })[]; + const { refundOrder, getPaymentURL } = useOrderAPI() const [notif, setNotif] = useState<Notification | undefined>(undefined) - if (result.unauthorized) return onUnauthorized() - if (result.notfound) return onNotFound() - if (!result.data) { - if (result.error) return onLoadError(result.error) - instances = [] - } else { - instances = result.data.orders.map(o => ({ ...o, id: o.order_id })) - } - interface Values { - filter: [] - } + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) const isPaidActive = filter.paid === 'yes' ? "is-active" : '' const isRefundedActive = filter.refunded === 'yes' ? "is-active" : '' @@ -72,11 +63,6 @@ export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNo const isAllActive = filter.paid === undefined && filter.refunded === undefined && filter.wired === undefined ? 'is-active' : '' return <section class="section is-main-section"> - <NotificationCard notification={{ - message: 'DEMO WARNING', - type: 'WARN', - description: 'refund button is being forced in the first row, other depends on the refundable property' - }} /> <NotificationCard notification={notif} /> <div class="columns"> @@ -117,10 +103,10 @@ export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNo dateReceiver={setNewDate} /> - <CardTable instances={instances} + <CardTable instances={result.data.orders.map(o => ({ ...o, id: o.order_id }))} onCreate={onCreate} onSelect={(order) => onSelect(order.id)} - onCopyURL={(id) => getPaymentURL(id).then(copyToClipboard)} + onCopyURL={(id) => getPaymentURL(id).then((resp) => copyToClipboard(resp.data))} onRefund={(id, value) => refundOrder(id, value) .then(() => setNotif({ message: 'refund created successfully', diff --git a/packages/frontend/src/paths/instance/products/list/index.tsx b/packages/frontend/src/paths/instance/products/list/index.tsx @@ -21,7 +21,7 @@ import { h, VNode } from 'preact'; import { create } from 'yup/lib/Reference'; -import { SwrError } from '../../../../hooks/backend'; +import { HttpError, HttpResponseServerError, RequestInfo } from '../../../../hooks/backend'; import { useProductAPI } from "../../../../hooks/product"; import { CardTable } from './Table'; import logo from '../../../../assets/logo.jpeg'; @@ -34,25 +34,23 @@ import { NotificationCard } from '../../../../components/menu'; interface Props { onUnauthorized: () => VNode; onNotFound: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (e: HttpError) => VNode; } export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VNode { const result = useInstanceProducts() const { createProduct, deleteProduct } = useProductAPI() const { currency } = useConfigContext() - if (result.unauthorized) return onUnauthorized() - if (result.notfound) return onNotFound() - - if (!result.data) { - if (result.error) return onLoadError(result.error) - return <Loading /> - } + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) + return <section class="section is-main-section"> <NotificationCard notification={{ message: 'DEMO', - type:'WARN', - description:<ul> + type: 'WARN', + description: <ul> <li>image return object when api says string</li> </ul> }} /> @@ -72,7 +70,7 @@ export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VN unit: 'units', next_restock: { t_ms: 'never' }, //WTF? should not be required })} - onDelete={(prod: (MerchantBackend.Products.ProductDetail & {id:string})) => deleteProduct(prod.id)} + onDelete={(prod: (MerchantBackend.Products.ProductDetail & { id: string })) => deleteProduct(prod.id)} onUpdate={() => null} /> </section> diff --git a/packages/frontend/src/paths/instance/tips/list/index.tsx b/packages/frontend/src/paths/instance/tips/list/index.tsx @@ -23,13 +23,13 @@ import { h, VNode } from 'preact'; import { Loading } from '../../../../components/exception/loading'; import { useConfigContext } from '../../../../context/backend'; import { MerchantBackend } from '../../../../declaration'; -import { SwrError } from '../../../../hooks/backend'; +import { HttpError, HttpResponseServerError, RequestInfo } from '../../../../hooks/backend'; import { useInstanceTips, useTipsMutateAPI } from "../../../../hooks/tips"; import { CardTable } from './Table'; interface Props { onUnauthorized: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (e: HttpError) => VNode; onNotFound: () => VNode; } export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VNode { @@ -37,14 +37,11 @@ export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VN const { createReserve, deleteReserve } = useTipsMutateAPI() const { currency } = useConfigContext() - if (result.unauthorized) return onUnauthorized() - if (result.notfound) return onNotFound() + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) - if (!result.data) { - if (result.error) return onLoadError(result.error) - return <Loading /> - } - return <section class="section is-main-section"> <CardTable instances={result.data.reserves.filter(r => r.active).map(o => ({ ...o, id: o.reserve_pub }))} onCreate={() => createReserve({ diff --git a/packages/frontend/src/paths/instance/transfers/list/index.tsx b/packages/frontend/src/paths/instance/transfers/list/index.tsx @@ -22,27 +22,25 @@ import { h, VNode } from 'preact'; import { Loading } from '../../../../components/exception/loading'; import { useConfigContext } from '../../../../context/backend'; -import { SwrError } from '../../../../hooks/backend'; +import { MerchantBackend } from '../../../../declaration'; +import { HttpError, HttpResponseServerError, RequestInfo, SwrError } from '../../../../hooks/backend'; import { useInstanceTransfers, useTransferMutateAPI } from "../../../../hooks/transfer"; import { CardTable } from './Table'; interface Props { onUnauthorized: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (error: HttpError) => VNode; onNotFound: () => VNode; } export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VNode { const result = useInstanceTransfers() const { informTransfer } = useTransferMutateAPI() - const { currency } = useConfigContext() - if (result.unauthorized) return onUnauthorized() - if (result.notfound) return onNotFound(); + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) - if (!result.data) { - if (result.error) return onLoadError(result.error) - return <Loading /> - } return <section class="section is-main-section"> <CardTable instances={result.data.transfers.map(o => ({ ...o, id: String(o.transfer_serial_id) }))} onCreate={() => informTransfer({ diff --git a/packages/frontend/src/paths/instance/update/index.tsx b/packages/frontend/src/paths/instance/update/index.tsx @@ -19,7 +19,7 @@ import { Loading } from "../../../components/exception/loading"; import { UpdateTokenModal } from "../../../components/modal"; import { useInstanceContext } from "../../../context/backend"; import { MerchantBackend } from "../../../declaration"; -import { SwrError } from "../../../hooks/backend"; +import { HttpError, HttpResponseServerError, RequestInfo, SwrError } from "../../../hooks/backend"; import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance"; import { UpdatePage } from "./UpdatePage"; @@ -29,28 +29,25 @@ export interface Props { onUnauthorized: () => VNode; onNotFound: () => VNode; - onLoadError: (e: SwrError) => VNode; + onLoadError: (e: HttpError) => VNode; onUpdateError: (e: Error) => void; } export default function Update({ onBack, onConfirm, onLoadError, onNotFound, onUpdateError, onUnauthorized }: Props): VNode { const { updateInstance } = useInstanceAPI(); - const details = useInstanceDetails() + const result = useInstanceDetails() - if (details.unauthorized) return onUnauthorized() - if (details.notfound) return onNotFound(); - - if (!details.data) { - if (details.error) return onLoadError(details.error) - return <Loading /> - } + if (result.clientError && result.isUnauthorized) return onUnauthorized() + if (result.clientError && result.isNotfound) return onNotFound() + if (result.loading) return <Loading /> + if (!result.ok) return onLoadError(result) return <Fragment> <UpdatePage onBack={onBack} isLoading={false} - selected={details.data} + selected={result.data} onUpdate={(d: MerchantBackend.Instances.InstanceReconfigurationMessage, t?: MerchantBackend.Instances.InstanceAuthConfigurationMessage): Promise<void> => { return updateInstance(d, t).then(onConfirm).catch(onUpdateError) }} /> diff --git a/packages/frontend/src/utils/constants.ts b/packages/frontend/src/utils/constants.ts @@ -30,4 +30,4 @@ export const INSTANCE_ID_LOOKUP = /^\/instances\/([^/]*)\/?$/ export const PAGE_SIZE = 20 // how bigger can be the result set // after this threshold, load more with move the cursor -export const MAX_RESULT_SIZE = 39; -\ No newline at end of file +export const MAX_RESULT_SIZE = PAGE_SIZE*2-1; +\ No newline at end of file