aboutsummaryrefslogtreecommitdiff
path: root/packages/merchant-backoffice-ui/src/hooks
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2022-10-24 10:46:14 +0200
committerFlorian Dold <florian@dold.me>2022-10-24 10:46:14 +0200
commit3e060b80428943c6562250a6ff77eff10a0259b7 (patch)
treed08472bc5ca28621c62ac45b229207d8215a9ea7 /packages/merchant-backoffice-ui/src/hooks
parentfb52ced35ac872349b0e1062532313662552ff6c (diff)
downloadwallet-core-3e060b80428943c6562250a6ff77eff10a0259b7.tar.xz
repo: integrate packages from former merchant-backoffice.git
Diffstat (limited to 'packages/merchant-backoffice-ui/src/hooks')
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/async.ts76
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/backend.ts319
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/index.ts110
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/instance.ts292
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/listener.ts81
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/notifications.ts48
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/order.ts323
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/product.ts187
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/reserves.ts218
-rw-r--r--packages/merchant-backoffice-ui/src/hooks/transfer.ts217
10 files changed, 1871 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/hooks/async.ts b/packages/merchant-backoffice-ui/src/hooks/async.ts
new file mode 100644
index 000000000..fd550043b
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/async.ts
@@ -0,0 +1,76 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+import { useState } from "preact/hooks";
+import { cancelPendingRequest } from "./backend";
+
+export interface Options {
+ slowTolerance: number,
+}
+
+export interface AsyncOperationApi<T> {
+ request: (...a: any) => void,
+ cancel: () => void,
+ data: T | undefined,
+ isSlow: boolean,
+ isLoading: boolean,
+ error: string | undefined
+}
+
+export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }): AsyncOperationApi<T> {
+ const [data, setData] = useState<T | undefined>(undefined);
+ const [isLoading, setLoading] = useState<boolean>(false);
+ const [error, setError] = useState<any>(undefined);
+ const [isSlow, setSlow] = useState(false)
+
+ const request = async (...args: any) => {
+ if (!fn) return;
+ setLoading(true);
+
+ const handler = setTimeout(() => {
+ setSlow(true)
+ }, tooLong)
+
+ try {
+ const result = await fn(...args);
+ setData(result);
+ } catch (error) {
+ setError(error);
+ }
+ setLoading(false);
+ setSlow(false)
+ clearTimeout(handler)
+ };
+
+ function cancel() {
+ cancelPendingRequest()
+ setLoading(false);
+ setSlow(false)
+ }
+
+ return {
+ request,
+ cancel,
+ data,
+ isSlow,
+ isLoading,
+ error
+ };
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/backend.ts b/packages/merchant-backoffice-ui/src/hooks/backend.ts
new file mode 100644
index 000000000..789cfc81c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/backend.ts
@@ -0,0 +1,319 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import { useSWRConfig } from "swr";
+import axios, { AxiosError, AxiosResponse } from "axios";
+import { MerchantBackend } from "../declaration";
+import { useBackendContext } from "../context/backend";
+import { useEffect, useState } from "preact/hooks";
+import { DEFAULT_REQUEST_TIMEOUT } from "../utils/constants";
+import { axiosHandler, removeAxiosCancelToken } from "../utils/switchableAxios";
+
+export function useMatchMutate(): (
+ re: RegExp,
+ value?: unknown
+) => Promise<any> {
+ const { cache, mutate } = useSWRConfig();
+
+ if (!(cache instanceof Map)) {
+ throw new Error(
+ "matchMutate requires the cache provider to be a Map instance"
+ );
+ }
+
+ return function matchRegexMutate(re: RegExp, value?: unknown) {
+ const allKeys = Array.from(cache.keys());
+ // console.log(allKeys)
+ const keys = allKeys.filter((key) => re.test(key));
+ // console.log(allKeys.length, keys.length)
+ const mutations = keys.map((key) => {
+ // console.log(key)
+ mutate(key, value, true);
+ });
+ return Promise.all(mutations);
+ };
+}
+
+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: unknown;
+ data: unknown;
+ status: number;
+}
+
+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;
+
+ data: T;
+ 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: unknown;
+ status: number;
+ message: string;
+}
+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: unknown;
+ message: string;
+}
+
+type Methods = "get" | "post" | "patch" | "delete" | "put";
+
+interface RequestOptions {
+ method?: Methods;
+ token?: string;
+ data?: unknown;
+ params?: unknown;
+}
+
+function buildRequestOk<T>(
+ res: AxiosResponse<T>,
+ url: string,
+ hasToken: boolean
+): HttpResponseOk<T> {
+ return {
+ ok: true,
+ data: res.data,
+ info: {
+ params: res.config.params,
+ data: res.config.data,
+ url,
+ hasToken,
+ status: res.status,
+ },
+ };
+}
+
+// function buildResponse<T>(data?: T, error?: MerchantBackend.ErrorDetail, isValidating?: boolean): HttpResponse<T> {
+// if (isValidating) return {loading: true}
+// if (error) return buildRequestFailed()
+// }
+
+function buildRequestFailed(
+ ex: AxiosError<MerchantBackend.ErrorDetail>,
+ url: string,
+ hasToken: boolean
+):
+ | HttpResponseClientError
+ | HttpResponseServerError
+ | HttpResponseUnexpectedError {
+ const status = ex.response?.status;
+
+ const info: RequestInfo = {
+ data: ex.request?.data,
+ params: ex.request?.params,
+ url,
+ hasToken,
+ status: status || 0,
+ };
+
+ 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} (code ${ex.response?.data?.code})` ||
+ ex.message,
+ error: ex.response?.data,
+ };
+ return error;
+ }
+
+ const error: HttpResponseUnexpectedError = {
+ info,
+ status,
+ error: ex,
+ message: ex.message,
+ };
+
+ return error;
+}
+
+const CancelToken = axios.CancelToken;
+let source = CancelToken.source();
+
+export function cancelPendingRequest(): void {
+ source.cancel("canceled by the user");
+ source = CancelToken.source();
+}
+
+export function isAxiosError<T>(
+ error: AxiosError | any
+): error is AxiosError<T> {
+ return error && error.isAxiosError;
+}
+
+export async function request<T>(
+ url: string,
+ options: RequestOptions = {}
+): Promise<HttpResponseOk<T>> {
+ const headers = options.token
+ ? { Authorization: `Bearer ${options.token}` }
+ : undefined;
+
+ try {
+ const res = await axiosHandler({
+ url,
+ responseType: "json",
+ headers,
+ cancelToken: !removeAxiosCancelToken ? source.token : undefined,
+ method: options.method || "get",
+ data: options.data,
+ params: options.params,
+ timeout: DEFAULT_REQUEST_TIMEOUT * 1000,
+ });
+ return buildRequestOk<T>(res, url, !!options.token);
+ } catch (e) {
+ if (isAxiosError<MerchantBackend.ErrorDetail>(e)) {
+ const error = buildRequestFailed(e, url, !!options.token);
+ throw error;
+ }
+ throw e;
+ }
+}
+
+export function multiFetcher<T>(
+ urls: string[],
+ token: string,
+ backend: string
+): Promise<HttpResponseOk<T>[]> {
+ return Promise.all(urls.map((url) => fetcher<T>(url, token, backend)));
+}
+
+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();
+
+ type Type = MerchantBackend.Instances.InstancesResponse;
+
+ const [result, setResult] = useState<HttpResponse<Type>>({ loading: true });
+
+ useEffect(() => {
+ request<Type>(`${url}/management/instances`, { token })
+ .then((data) => setResult(data))
+ .catch((error) => setResult(error));
+ }, [url, token]);
+
+ return result;
+}
+
+export function useBackendConfig(): HttpResponse<MerchantBackend.VersionResponse> {
+ const { url, token } = useBackendContext();
+
+ type Type = MerchantBackend.VersionResponse;
+
+ const [result, setResult] = useState<HttpResponse<Type>>({ loading: true });
+
+ useEffect(() => {
+ request<Type>(`${url}/config`, { token })
+ .then((data) => setResult(data))
+ .catch((error) => setResult(error));
+ }, [url, token]);
+
+ return result;
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/index.ts b/packages/merchant-backoffice-ui/src/hooks/index.ts
new file mode 100644
index 000000000..a647e3e6c
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/index.ts
@@ -0,0 +1,110 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { StateUpdater, useCallback, useState } from "preact/hooks";
+import { ValueOrFunction } from '../utils/types';
+
+
+const calculateRootPath = () => {
+ const rootPath = typeof window !== undefined ? window.location.origin + window.location.pathname : '/'
+ return rootPath
+}
+
+export function useBackendURL(url?: string): [string, boolean, StateUpdater<string>, () => void] {
+ const [value, setter] = useNotNullLocalStorage('backend-url', url || calculateRootPath())
+ const [triedToLog, setTriedToLog] = useLocalStorage('tried-login')
+
+ const checkedSetter = (v: ValueOrFunction<string>) => {
+ setTriedToLog('yes')
+ return setter(p => (v instanceof Function ? v(p) : v).replace(/\/$/, ''))
+ }
+
+ const resetBackend = () => {
+ setTriedToLog(undefined)
+ }
+ return [value, !!triedToLog, checkedSetter, resetBackend]
+}
+
+export function useBackendDefaultToken(initialValue?: string): [string | undefined, StateUpdater<string | undefined>] {
+ return useLocalStorage('backend-token', initialValue)
+}
+
+export function useBackendInstanceToken(id: string): [string | undefined, StateUpdater<string | undefined>] {
+ const [token, setToken] = useLocalStorage(`backend-token-${id}`)
+ const [defaultToken, defaultSetToken] = useBackendDefaultToken()
+
+ // instance named 'default' use the default token
+ if (id === 'default') {
+ return [defaultToken, defaultSetToken]
+ }
+
+ return [token, setToken]
+}
+
+export function useLang(initial?: string): [string, StateUpdater<string>] {
+ const browserLang = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined;
+ const defaultLang = (browserLang || initial || 'en').substring(0, 2)
+ return useNotNullLocalStorage('lang-preference', defaultLang)
+}
+
+export function useLocalStorage(key: string, initialValue?: string): [string | undefined, StateUpdater<string | undefined>] {
+ const [storedValue, setStoredValue] = useState<string | undefined>((): string | undefined => {
+ return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue;
+ });
+
+ const setValue = (value?: string | ((val?: string) => string | undefined)) => {
+ setStoredValue(p => {
+ const toStore = value instanceof Function ? value(p) : value
+ if (typeof window !== "undefined") {
+ if (!toStore) {
+ window.localStorage.removeItem(key)
+ } else {
+ window.localStorage.setItem(key, toStore);
+ }
+ }
+ return toStore
+ })
+ };
+
+ return [storedValue, setValue];
+}
+
+export function useNotNullLocalStorage(key: string, initialValue: string): [string, StateUpdater<string>] {
+ const [storedValue, setStoredValue] = useState<string>((): string => {
+ return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue;
+ });
+
+ const setValue = (value: string | ((val: string) => string)) => {
+ const valueToStore = value instanceof Function ? value(storedValue) : value;
+ setStoredValue(valueToStore);
+ if (typeof window !== "undefined") {
+ if (!valueToStore) {
+ window.localStorage.removeItem(key)
+ } else {
+ window.localStorage.setItem(key, valueToStore);
+ }
+ }
+ };
+
+ return [storedValue, setValue];
+}
+
+
diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts
new file mode 100644
index 000000000..748bb82af
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/instance.ts
@@ -0,0 +1,292 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import useSWR, { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend";
+import { useInstanceContext } from "../context/instance";
+import { MerchantBackend } from "../declaration";
+import {
+ fetcher,
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ request,
+ useMatchMutate,
+} from "./backend";
+
+interface InstanceAPI {
+ updateInstance: (
+ data: MerchantBackend.Instances.InstanceReconfigurationMessage
+ ) => Promise<void>;
+ deleteInstance: () => Promise<void>;
+ clearToken: () => Promise<void>;
+ setNewToken: (token: string) => Promise<void>;
+}
+
+export function useAdminAPI(): AdminAPI {
+ const { url, token } = useBackendContext();
+ const mutateAll = useMatchMutate();
+
+ const createInstance = async (
+ instance: MerchantBackend.Instances.InstanceConfigurationMessage
+ ): Promise<void> => {
+ await request(`${url}/management/instances`, {
+ method: "post",
+ token,
+ data: instance,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const deleteInstance = async (id: string): Promise<void> => {
+ await request(`${url}/management/instances/${id}`, {
+ method: "delete",
+ token,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const purgeInstance = async (id: string): Promise<void> => {
+ await request(`${url}/management/instances/${id}`, {
+ method: "delete",
+ token,
+ params: {
+ purge: "YES",
+ },
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ return { createInstance, deleteInstance, purgeInstance };
+}
+
+export interface AdminAPI {
+ createInstance: (
+ data: MerchantBackend.Instances.InstanceConfigurationMessage
+ ) => Promise<void>;
+ deleteInstance: (id: string) => Promise<void>;
+ purgeInstance: (id: string) => Promise<void>;
+}
+
+export function useManagementAPI(instanceId: string): InstanceAPI {
+ const mutateAll = useMatchMutate();
+ const { url, token, updateLoginStatus } = useBackendContext();
+
+ const updateInstance = async (
+ instance: MerchantBackend.Instances.InstanceReconfigurationMessage
+ ): Promise<void> => {
+ await request(`${url}/management/instances/${instanceId}`, {
+ method: "patch",
+ token,
+ data: instance,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const deleteInstance = async (): Promise<void> => {
+ await request(`${url}/management/instances/${instanceId}`, {
+ method: "delete",
+ token,
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const clearToken = async (): Promise<void> => {
+ await request(`${url}/management/instances/${instanceId}/auth`, {
+ method: "post",
+ token,
+ data: { method: "external" },
+ });
+
+ mutateAll(/\/management\/instances/);
+ };
+
+ const setNewToken = async (newToken: string): Promise<void> => {
+ await request(`${url}/management/instances/${instanceId}/auth`, {
+ method: "post",
+ token,
+ data: { method: "token", token: newToken },
+ });
+
+ updateLoginStatus(url, newToken)
+ mutateAll(/\/management\/instances/);
+ };
+
+ return { updateInstance, deleteInstance, setNewToken, clearToken };
+}
+
+export function useInstanceAPI(): InstanceAPI {
+ const { mutate } = useSWRConfig();
+ const { url: baseUrl, token: adminToken, updateLoginStatus } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: adminToken }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken };
+
+ const updateInstance = async (
+ instance: MerchantBackend.Instances.InstanceReconfigurationMessage
+ ): Promise<void> => {
+ await request(`${url}/private/`, {
+ method: "patch",
+ token,
+ data: instance,
+ });
+
+ if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null);
+ mutate([`/private/`, token, url], null);
+ };
+
+ const deleteInstance = async (): Promise<void> => {
+ await request(`${url}/private/`, {
+ method: "delete",
+ token: adminToken,
+ });
+
+ if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null);
+ mutate([`/private/`, token, url], null);
+ };
+
+ const clearToken = async (): Promise<void> => {
+ await request(`${url}/private/auth`, {
+ method: "post",
+ token,
+ data: { method: "external" },
+ });
+
+ mutate([`/private/`, token, url], null);
+ };
+
+ const setNewToken = async (newToken: string): Promise<void> => {
+ await request(`${url}/private/auth`, {
+ method: "post",
+ token,
+ data: { method: "token", token: newToken },
+ });
+
+ updateLoginStatus(baseUrl, newToken)
+ mutate([`/private/`, token, url], null);
+ };
+
+ return { updateInstance, deleteInstance, setNewToken, clearToken };
+}
+
+export function useInstanceDetails(): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken };
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>,
+ HttpError
+ >([`/private/`, token, url], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+type KYCStatus =
+ | { type: "ok" }
+ | { type: "redirect"; status: MerchantBackend.Instances.AccountKycRedirects };
+
+export function useInstanceKYCDetails(): HttpResponse<KYCStatus> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken };
+
+ const { data, error } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.AccountKycRedirects>,
+ HttpError
+ >([`/private/kyc`, token, url], fetcher, {
+ refreshInterval: 5000,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ if (data) {
+ if (data.info?.status === 202)
+ return { ok: true, data: { type: "redirect", status: data.data } };
+ return { ok: true, data: { type: "ok" } };
+ }
+ if (error) return error;
+ return { loading: true };
+}
+
+export function useManagedInstanceDetails(
+ instanceId: string
+): HttpResponse<MerchantBackend.Instances.QueryInstancesResponse> {
+ const { url, token } = useBackendContext();
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.QueryInstancesResponse>,
+ HttpError
+ >([`/management/instances/${instanceId}`, token, url], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ });
+
+ 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, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Instances.InstancesResponse>,
+ HttpError
+ >(["/management/instances", token, url], fetcher);
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/listener.ts b/packages/merchant-backoffice-ui/src/hooks/listener.ts
new file mode 100644
index 000000000..e7e3327b7
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/listener.ts
@@ -0,0 +1,81 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { useState } from "preact/hooks";
+
+/**
+ * This component is used when a component wants one child to have a trigger for
+ * an action (a button) and other child have the action implemented (like
+ * gathering information with a form). The difference with other approaches is
+ * that in this case the parent component is not holding the state.
+ *
+ * It will return a subscriber and activator.
+ *
+ * The activator may be undefined, if it is undefined it is indicating that the
+ * subscriber is not ready to be called.
+ *
+ * The subscriber will receive a function (the listener) that will be call when the
+ * activator runs. The listener must return the collected information.
+ *
+ * As a result, when the activator is triggered by a child component, the
+ * @action function is called receives the information from the listener defined by other
+ * child component
+ *
+ * @param action from <T> to <R>
+ * @returns activator and subscriber, undefined activator means that there is not subscriber
+ */
+
+export function useListener<T, R = any>(action: (r: T) => Promise<R>): [undefined | (() => Promise<R>), (listener?: () => T) => void] {
+ type RunnerHandler = { toBeRan?: () => Promise<R>; };
+ const [state, setState] = useState<RunnerHandler>({});
+
+ /**
+ * subscriber will receive a method that will be call when the activator runs
+ *
+ * @param listener function to be run when the activator runs
+ */
+ const subscriber = (listener?: () => T) => {
+ if (listener) {
+ setState({
+ toBeRan: () => {
+ const whatWeGetFromTheListener = listener();
+ return action(whatWeGetFromTheListener);
+ }
+ });
+ } else {
+ setState({
+ toBeRan: undefined
+ })
+ }
+ };
+
+ /**
+ * activator will call runner if there is someone subscribed
+ */
+ const activator = state.toBeRan ? async () => {
+ if (state.toBeRan) {
+ return state.toBeRan();
+ }
+ return Promise.reject();
+ } : undefined;
+
+ return [activator, subscriber];
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/notifications.ts b/packages/merchant-backoffice-ui/src/hooks/notifications.ts
new file mode 100644
index 000000000..1c0c37308
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/notifications.ts
@@ -0,0 +1,48 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+*
+* @author Sebastian Javier Marchano (sebasjm)
+*/
+
+import { useState } from "preact/hooks";
+import { Notification } from '../utils/types';
+
+interface Result {
+ notifications: Notification[];
+ pushNotification: (n: Notification) => void;
+ removeNotification: (n: Notification) => void;
+}
+
+type NotificationWithDate = Notification & { since: Date }
+
+export function useNotifications(initial: Notification[] = [], timeout = 3000): Result {
+ const [notifications, setNotifications] = useState<(NotificationWithDate)[]>(initial.map(i => ({...i, since: new Date() })))
+
+ const pushNotification = (n: Notification): void => {
+ const entry = { ...n, since: new Date() }
+ setNotifications(ns => [...ns, entry])
+ if (n.type !== 'ERROR') setTimeout(() => {
+ setNotifications(ns => ns.filter(x => x.since !== entry.since))
+ }, timeout)
+ }
+
+ const removeNotification = (notif: Notification) => {
+ setNotifications((ns: NotificationWithDate[]) => ns.filter(n => n !== notif))
+ }
+ return { notifications, pushNotification, removeNotification }
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/order.ts b/packages/merchant-backoffice-ui/src/hooks/order.ts
new file mode 100644
index 000000000..d0829683d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/order.ts
@@ -0,0 +1,323 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useEffect, useState } from "preact/hooks";
+import useSWR, { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend";
+import { useInstanceContext } from "../context/instance";
+import { MerchantBackend } from "../declaration";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants";
+import {
+ fetcher,
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ request,
+ useMatchMutate,
+} from "./backend";
+
+export interface OrderAPI {
+ //FIXME: add OutOfStockResponse on 410
+ 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<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();
+ const params: any = {};
+ if (paid !== undefined) params.paid = paid;
+ if (delta !== undefined) params.delta = delta;
+ if (refunded !== undefined) params.refunded = refunded;
+ if (wired !== undefined) params.wired = wired;
+ if (date_ms !== undefined) params.date_ms = date_ms;
+ return request<T>(`${backend}${url}`, { token, params });
+}
+
+export function useOrderAPI(): OrderAPI {
+ const mutateAll = useMatchMutate();
+ const { url: baseUrl, token: adminToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? {
+ url: baseUrl,
+ token: adminToken,
+ }
+ : {
+ url: `${baseUrl}/instances/${id}`,
+ token: instanceToken,
+ };
+
+ const createOrder = async (
+ data: MerchantBackend.Orders.PostOrderRequest
+ ): Promise<HttpResponseOk<MerchantBackend.Orders.PostOrderResponse>> => {
+ const res = await request<MerchantBackend.Orders.PostOrderResponse>(
+ `${url}/private/orders`,
+ {
+ method: "post",
+ token,
+ data,
+ }
+ );
+ await mutateAll(/.*private\/orders.*/);
+ // mutate('')
+ return res;
+ };
+ const refundOrder = async (
+ orderId: string,
+ data: MerchantBackend.Orders.RefundRequest
+ ): Promise<HttpResponseOk<MerchantBackend.Orders.MerchantRefundResponse>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<MerchantBackend.Orders.MerchantRefundResponse>(
+ `${url}/private/orders/${orderId}/refund`,
+ {
+ method: "post",
+ token,
+ data,
+ }
+ );
+
+ // order list returns refundable information, so we must evict everything
+ await mutateAll(/.*private\/orders.*/);
+ return res
+ };
+
+ const forgetOrder = async (
+ orderId: string,
+ data: MerchantBackend.Orders.ForgetRequest
+ ): Promise<HttpResponseOk<void>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<void>(`${url}/private/orders/${orderId}/forget`, {
+ method: "patch",
+ token,
+ data,
+ });
+ // we may be forgetting some fields that are pare of the listing, so we must evict everything
+ await mutateAll(/.*private\/orders.*/);
+ return res
+ };
+ const deleteOrder = async (
+ orderId: string
+ ): Promise<HttpResponseOk<void>> => {
+ mutateAll(/@"\/private\/orders"@/);
+ const res = request<void>(`${url}/private/orders/${orderId}`, {
+ method: "delete",
+ token,
+ });
+ await mutateAll(/.*private\/orders.*/);
+ return res
+ };
+
+ 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 { createOrder, forgetOrder, deleteOrder, refundOrder, getPaymentURL };
+}
+
+export function useOrderDetails(
+ oderId: string
+): HttpResponse<MerchantBackend.Orders.MerchantOrderStatusResponse> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken, }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken, };
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Orders.MerchantOrderStatusResponse>,
+ HttpError
+ >([`/private/orders/${oderId}`, token, url], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+export interface InstanceOrderFilter {
+ paid?: YesOrNo;
+ refunded?: YesOrNo;
+ wired?: YesOrNo;
+ date?: Date;
+}
+
+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();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken, }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken, };
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.date ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * 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,
+ 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,
+ 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<
+ 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]);
+
+ if (beforeError) return beforeError;
+ if (afterError) return afterError;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd = afterData && afterData.data.orders.length < totalAfter;
+ const isReachingStart = args?.date === undefined ||
+ (beforeData && beforeData.data.orders.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) 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_s;
+ if (from && from !== "never" && updateFilter) updateFilter(new Date(from * 1000));
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) 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_s;
+ if (from && from !== "never" && updateFilter) updateFilter(new Date(from * 1000));
+ }
+ },
+ };
+
+ const orders =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.orders
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.orders);
+ if (loadingAfter || loadingBefore) return { loading: true, data: { orders } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { orders }, ...pagination };
+ }
+ return { loading: true };
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/product.ts b/packages/merchant-backoffice-ui/src/hooks/product.ts
new file mode 100644
index 000000000..c99542bc9
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/product.ts
@@ -0,0 +1,187 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import useSWR, { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend";
+import { useInstanceContext } from "../context/instance";
+import { MerchantBackend, WithId } from "../declaration";
+import {
+ fetcher,
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ multiFetcher,
+ request,
+ useMatchMutate
+} from "./backend";
+
+export interface ProductAPI {
+ createProduct: (
+ data: MerchantBackend.Products.ProductAddDetail
+ ) => Promise<void>;
+ updateProduct: (
+ id: string,
+ data: MerchantBackend.Products.ProductPatchDetail
+ ) => Promise<void>;
+ deleteProduct: (id: string) => Promise<void>;
+ lockProduct: (
+ id: string,
+ data: MerchantBackend.Products.LockRequest
+ ) => Promise<void>;
+}
+
+export function useProductAPI(): ProductAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+ const { url: baseUrl, token: adminToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: adminToken, }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken, };
+
+ const createProduct = async (
+ data: MerchantBackend.Products.ProductAddDetail
+ ): Promise<void> => {
+ const res = await request(`${url}/private/products`, {
+ method: "post",
+ token,
+ data,
+ });
+
+ return await mutateAll(/.*"\/private\/products.*/);
+ };
+
+ const updateProduct = async (
+ productId: string,
+ data: MerchantBackend.Products.ProductPatchDetail
+ ): Promise<void> => {
+ const r = await request(`${url}/private/products/${productId}`, {
+ method: "patch",
+ token,
+ data,
+ });
+
+ return await mutateAll(/.*"\/private\/products.*/);
+ };
+
+ const deleteProduct = async (productId: string): Promise<void> => {
+ await request(`${url}/private/products/${productId}`, {
+ method: "delete",
+ token,
+ });
+ await mutate([`/private/products`, token, url]);
+ };
+
+ const lockProduct = async (
+ productId: string,
+ data: MerchantBackend.Products.LockRequest
+ ): Promise<void> => {
+ await request(`${url}/private/products/${productId}/lock`, {
+ method: "post",
+ token,
+ data,
+ });
+
+ return await mutateAll(/.*"\/private\/products.*/);
+ };
+
+ return { createProduct, updateProduct, deleteProduct, lockProduct };
+}
+
+export function useInstanceProducts(): HttpResponse<
+ (MerchantBackend.Products.ProductDetail & WithId)[]
+> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken, }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken, };
+
+ const { data: list, error: listError } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.InventorySummaryResponse>,
+ HttpError
+ >([`/private/products`, token, url], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ const paths = (list?.data.products || []).map(
+ (p) => `/private/products/${p.product_id}`
+ );
+ const { data: products, error: productError } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.ProductDetail>[],
+ HttpError
+ >([paths, token, url], multiFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+
+ if (listError) return listError;
+ if (productError) return productError;
+
+ if (products) {
+ const dataWithId = products.map((d) => {
+ //take the id from the queried url
+ return {
+ ...d.data,
+ id: d.info?.url.replace(/.*\/private\/products\//, "") || "",
+ };
+ });
+ return { ok: true, data: dataWithId };
+ }
+ return { loading: true };
+}
+
+export function useProductDetails(
+ productId: string
+): HttpResponse<MerchantBackend.Products.ProductDetail> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? {
+ url: baseUrl,
+ token: baseToken,
+ }
+ : {
+ url: `${baseUrl}/instances/${id}`,
+ token: instanceToken,
+ };
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Products.ProductDetail>,
+ HttpError
+ >([`/private/products/${productId}`, token, url], fetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/reserves.ts b/packages/merchant-backoffice-ui/src/hooks/reserves.ts
new file mode 100644
index 000000000..7a662dfbc
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/reserves.ts
@@ -0,0 +1,218 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import useSWR, { useSWRConfig } from "swr";
+import { useBackendContext } from "../context/backend";
+import { useInstanceContext } from "../context/instance";
+import { MerchantBackend } from "../declaration";
+import {
+ fetcher,
+ HttpError,
+ HttpResponse,
+ HttpResponseOk,
+ request,
+ useMatchMutate,
+} from "./backend";
+
+export function useReservesAPI(): ReserveMutateAPI {
+ const mutateAll = useMatchMutate();
+ const { mutate } = useSWRConfig();
+ const { url: baseUrl, token: adminToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: adminToken, }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken, };
+
+ const createReserve = async (
+ data: MerchantBackend.Tips.ReserveCreateRequest
+ ): Promise<
+ HttpResponseOk<MerchantBackend.Tips.ReserveCreateConfirmation>
+ > => {
+ const res = await request<MerchantBackend.Tips.ReserveCreateConfirmation>(
+ `${url}/private/reserves`,
+ {
+ method: "post",
+ token,
+ data,
+ }
+ );
+
+ //evict reserve list query
+ await mutateAll(/.*private\/reserves.*/);
+
+ return res;
+ };
+
+ const authorizeTipReserve = async (
+ pub: string,
+ data: MerchantBackend.Tips.TipCreateRequest
+ ): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => {
+ const res = await request<MerchantBackend.Tips.TipCreateConfirmation>(
+ `${url}/private/reserves/${pub}/authorize-tip`,
+ {
+ method: "post",
+ token,
+ data,
+ }
+ );
+
+ //evict reserve details query
+ await mutate([`/private/reserves/${pub}`, token, url]);
+
+ return res;
+ };
+
+ const authorizeTip = async (
+ data: MerchantBackend.Tips.TipCreateRequest
+ ): Promise<HttpResponseOk<MerchantBackend.Tips.TipCreateConfirmation>> => {
+ const res = await request<MerchantBackend.Tips.TipCreateConfirmation>(
+ `${url}/private/tips`,
+ {
+ method: "post",
+ token,
+ data,
+ }
+ );
+
+ //evict all details query
+ await mutateAll(/.*private\/reserves\/.*/);
+
+ return res;
+ };
+
+ const deleteReserve = async (pub: string): Promise<HttpResponse<void>> => {
+ const res = await request<void>(`${url}/private/reserves/${pub}`, {
+ method: "delete",
+ token,
+ });
+
+ //evict reserve list query
+ await mutateAll(/.*private\/reserves.*/);
+
+ return res;
+ };
+
+ return { createReserve, authorizeTip, authorizeTipReserve, deleteReserve };
+}
+
+export interface ReserveMutateAPI {
+ 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 useInstanceReserves(): HttpResponse<MerchantBackend.Tips.TippingReserveStatus> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken, }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken, };
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Tips.TippingReserveStatus>,
+ HttpError
+ >([`/private/reserves`, token, url], fetcher);
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+export function useReserveDetails(
+ reserveId: string
+): HttpResponse<MerchantBackend.Tips.ReserveDetail> {
+ const { url: baseUrl } = useBackendContext();
+ const { token, id: instanceId, admin } = useInstanceContext();
+
+ const url = !admin ? baseUrl : `${baseUrl}/instances/${instanceId}`;
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Tips.ReserveDetail>,
+ HttpError
+ >([`/private/reserves/${reserveId}`, token, url], reserveDetailFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+export function useTipDetails(
+ tipId: string
+): HttpResponse<MerchantBackend.Tips.TipDetails> {
+ const { url: baseUrl } = useBackendContext();
+ const { token, id: instanceId, admin } = useInstanceContext();
+
+ const url = !admin ? baseUrl : `${baseUrl}/instances/${instanceId}`;
+
+ const { data, error, isValidating } = useSWR<
+ HttpResponseOk<MerchantBackend.Tips.TipDetails>,
+ HttpError
+ >([`/private/tips/${tipId}`, token, url], tipsDetailFetcher, {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ });
+
+ if (isValidating) return { loading: true, data: data?.data };
+ if (data) return data;
+ if (error) return error;
+ return { loading: true };
+}
+
+function reserveDetailFetcher<T>(
+ url: string,
+ token: string,
+ backend: string
+): Promise<HttpResponseOk<T>> {
+ return request<T>(`${backend}${url}`, {
+ token,
+ params: {
+ tips: "yes",
+ },
+ });
+}
+
+function tipsDetailFetcher<T>(
+ url: string,
+ token: string,
+ backend: string
+): Promise<HttpResponseOk<T>> {
+ return request<T>(`${backend}${url}`, {
+ token,
+ params: {
+ pickups: "yes",
+ },
+ });
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/transfer.ts b/packages/merchant-backoffice-ui/src/hooks/transfer.ts
new file mode 100644
index 000000000..0c12d6d4d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/hooks/transfer.ts
@@ -0,0 +1,217 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { MerchantBackend } from "../declaration";
+import { useBackendContext } from "../context/backend";
+import {
+ request,
+ HttpResponse,
+ HttpError,
+ HttpResponseOk,
+ HttpResponsePaginated,
+ useMatchMutate,
+} from "./backend";
+import useSWR from "swr";
+import { useInstanceContext } from "../context/instance";
+import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils/constants";
+import { useEffect, useState } from "preact/hooks";
+
+async function transferFetcher<T>(
+ url: string,
+ token: string,
+ backend: string,
+ payto_uri?: string,
+ verified?: string,
+ position?: string,
+ delta?: number
+): Promise<HttpResponseOk<T>> {
+ const params: any = {};
+ if (payto_uri !== undefined) params.payto_uri = payto_uri;
+ if (verified !== undefined) params.verified = verified;
+ if (delta !== undefined) {
+ params.limit = delta;
+ }
+ if (position !== undefined) params.offset = position;
+
+ return request<T>(`${backend}${url}`, { token, params });
+}
+
+export function useTransferAPI(): TransferAPI {
+ const mutateAll = useMatchMutate();
+ const { url: baseUrl, token: adminToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? {
+ url: baseUrl,
+ token: adminToken,
+ }
+ : {
+ url: `${baseUrl}/instances/${id}`,
+ token: instanceToken,
+ };
+
+ const informTransfer = async (
+ data: MerchantBackend.Transfers.TransferInformation
+ ): Promise<
+ HttpResponseOk<MerchantBackend.Transfers.MerchantTrackTransferResponse>
+ > => {
+ const res = await request<MerchantBackend.Transfers.MerchantTrackTransferResponse>(
+ `${url}/private/transfers`, {
+ method: "post",
+ token,
+ data,
+ });
+
+ await mutateAll(/.*private\/transfers.*/);
+ return res
+ };
+
+ return { informTransfer };
+}
+
+export interface TransferAPI {
+ informTransfer: (
+ data: MerchantBackend.Transfers.TransferInformation
+ ) => Promise<
+ HttpResponseOk<MerchantBackend.Transfers.MerchantTrackTransferResponse>
+ >;
+}
+
+export interface InstanceTransferFilter {
+ payto_uri?: string;
+ verified?: "yes" | "no";
+ position?: string;
+}
+
+export function useInstanceTransfers(
+ args?: InstanceTransferFilter,
+ updatePosition?: (id: string) => void
+): HttpResponsePaginated<MerchantBackend.Transfers.TransferList> {
+ const { url: baseUrl, token: baseToken } = useBackendContext();
+ const { token: instanceToken, id, admin } = useInstanceContext();
+
+ const { url, token } = !admin
+ ? { url: baseUrl, token: baseToken }
+ : { url: `${baseUrl}/instances/${id}`, token: instanceToken };
+
+ const [pageBefore, setPageBefore] = useState(1);
+ const [pageAfter, setPageAfter] = useState(1);
+
+ const totalAfter = pageAfter * PAGE_SIZE;
+ const totalBefore = args?.position !== undefined ? pageBefore * PAGE_SIZE : 0;
+
+ /**
+ * FIXME: this can be cleaned up a little
+ *
+ * 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,
+ isValidating: loadingBefore,
+ } = useSWR<HttpResponseOk<MerchantBackend.Transfers.TransferList>, HttpError>(
+ [
+ `/private/transfers`,
+ token,
+ url,
+ args?.payto_uri,
+ args?.verified,
+ args?.position,
+ totalBefore,
+ ],
+ transferFetcher
+ );
+ const {
+ data: afterData,
+ error: afterError,
+ isValidating: loadingAfter,
+ } = useSWR<HttpResponseOk<MerchantBackend.Transfers.TransferList>, HttpError>(
+ [
+ `/private/transfers`,
+ token,
+ url,
+ args?.payto_uri,
+ args?.verified,
+ args?.position,
+ -totalAfter,
+ ],
+ transferFetcher
+ );
+
+ //this will save last result
+ const [lastBefore, setLastBefore] = useState<
+ HttpResponse<MerchantBackend.Transfers.TransferList>
+ >({ loading: true });
+ const [lastAfter, setLastAfter] = useState<
+ HttpResponse<MerchantBackend.Transfers.TransferList>
+ >({ loading: true });
+ useEffect(() => {
+ if (afterData) setLastAfter(afterData);
+ if (beforeData) setLastBefore(beforeData);
+ }, [afterData, beforeData]);
+
+ if (beforeError) return beforeError;
+ if (afterError) return afterError;
+
+ // if the query returns less that we ask, then we have reach the end or beginning
+ const isReachingEnd = afterData && afterData.data.transfers.length < totalAfter;
+ const isReachingStart = args?.position === undefined ||
+ (beforeData && beforeData.data.transfers.length < totalBefore);
+
+ const pagination = {
+ isReachingEnd,
+ isReachingStart,
+ loadMore: () => {
+ if (!afterData || isReachingEnd) return;
+ if (afterData.data.transfers.length < MAX_RESULT_SIZE) {
+ setPageAfter(pageAfter + 1);
+ } else {
+ const from =
+ `${afterData.data
+ .transfers[afterData.data.transfers.length - 1]
+ .transfer_serial_id}`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ loadMorePrev: () => {
+ if (!beforeData || isReachingStart) return;
+ if (beforeData.data.transfers.length < MAX_RESULT_SIZE) {
+ setPageBefore(pageBefore + 1);
+ } else if (beforeData) {
+ const from =
+ `${beforeData.data
+ .transfers[beforeData.data.transfers.length - 1]
+ .transfer_serial_id}`;
+ if (from && updatePosition) updatePosition(from);
+ }
+ },
+ };
+
+ const transfers =
+ !beforeData || !afterData
+ ? []
+ : (beforeData || lastBefore).data.transfers
+ .slice()
+ .reverse()
+ .concat((afterData || lastAfter).data.transfers);
+ if (loadingAfter || loadingBefore)
+ return { loading: true, data: { transfers } };
+ if (beforeData && afterData) {
+ return { ok: true, data: { transfers }, ...pagination };
+ }
+ return { loading: true };
+}