diff options
| author | Florian Dold <florian@dold.me> | 2022-10-24 10:46:14 +0200 | 
|---|---|---|
| committer | Florian Dold <florian@dold.me> | 2022-10-24 10:46:14 +0200 | 
| commit | 3e060b80428943c6562250a6ff77eff10a0259b7 (patch) | |
| tree | d08472bc5ca28621c62ac45b229207d8215a9ea7 /packages/merchant-backoffice-ui/src/hooks | |
| parent | fb52ced35ac872349b0e1062532313662552ff6c (diff) | |
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.ts | 76 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/backend.ts | 319 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/index.ts | 110 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/instance.ts | 292 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/listener.ts | 81 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/notifications.ts | 48 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/order.ts | 323 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/product.ts | 187 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/reserves.ts | 218 | ||||
| -rw-r--r-- | packages/merchant-backoffice-ui/src/hooks/transfer.ts | 217 | 
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 }; +}  | 
