Compare commits

...

5 Commits

27 changed files with 394 additions and 69 deletions

View File

@ -24,17 +24,20 @@
"@types/react": "^18.0.21", "@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"axios": "^1.1.3",
"babel-loader": "^8.2.5", "babel-loader": "^8.2.5",
"css-loader": "^6.7.1", "css-loader": "^6.7.1",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"final-form": "^4.20.7", "final-form": "^4.20.7",
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"ignore-loader": "^0.1.2",
"markdown-to-jsx": "^7.1.7", "markdown-to-jsx": "^7.1.7",
"mini-css-extract-plugin": "^2.6.1", "mini-css-extract-plugin": "^2.6.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-final-form": "^6.5.9", "react-final-form": "^6.5.9",
"react-hook-form": "^7.38.0", "react-hook-form": "^7.38.0",
"react-loading-icons": "^1.1.0",
"react-router-dom": "^6.4.2", "react-router-dom": "^6.4.2",
"sass": "^1.55.0", "sass": "^1.55.0",
"sass-loader": "^13.1.0", "sass-loader": "^13.1.0",

9
src/api/axiosInstance.ts Normal file
View File

@ -0,0 +1,9 @@
import axios from "axios";
export const axiosInstance = axios.create({
baseURL: "http://localhost:5276",
timeout: 5000,
params : {
shopId: 10,
}
})

View File

@ -0,0 +1,37 @@
import { axiosInstance } from "./axiosInstance";
import { ApiToken, CustomerInfo } from "./types";
const API_TOKEN_NOT_FOUND_ERROR_CODE = 16;
/**
* Get the data of customer
*
* @param token The token, given from WOnD, of the customer
* @returns the data of the customer
*/
export async function fetchCustomerInfo(token: ApiToken, id: number): Promise<CustomerInfo> {
function timeout(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
await timeout(3000);
const res = await axiosInstance.get("/getCustomer", {
params: { token, id }
})
if (res.data.getCustomer.result == "ERROR"){
const code: number = res.data.getCustomer.errorCode;
// if (code == API_TOKEN_NOT_FOUND_ERROR_CODE)
// navigate('/')
const msg: string = res.data.getCustomer.message;
throw new Error(`Couldn't get user info\nError code: ${code}\nError message: ${msg}`);
}
const customerInfo: CustomerInfo = {
name: res.data.getCustomer.customer.description, // yes i know...
balance: res.data.getCustomer.customer.prepayBalanceCash
}
return customerInfo;
}

View File

@ -0,0 +1,32 @@
import { axiosInstance } from "./axiosInstance";
import { ApiToken, CustomerInfo } from "./types";
const API_TOKEN_NOT_FOUND_ERROR_CODE = 16;
/**
* Get the data of customer
*
* @param token The token, given from WOnD, of the customer
* @returns the data of the customer
*/
export async function getCustomerInfo(token: ApiToken, id: number): Promise<CustomerInfo> {
const res = await axiosInstance.get("/getCustomer", {
params: { token, id }
})
if (res.data.getCustomer.result == "ERROR"){
const code: number = res.data.getCustomer.errorCode;
// if (code == API_TOKEN_NOT_FOUND_ERROR_CODE)
// navigate('/')
const msg: string = res.data.getCustomer.message;
throw new Error(`Couldn't get user info\nError code: ${code}\nError message: ${msg}`);
}
const customerInfo: CustomerInfo = {
name: res.data.getCustomer.customer.description, // yes i know...
balance: res.data.getCustomer.customer.prepayBalanceCash
}
return customerInfo;
}

3
src/api/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from "./isTokenValid";
export * from "./login";
export * from "./fetchCustomerInfo";

35
src/api/isTokenValid.ts Normal file
View File

@ -0,0 +1,35 @@
import { axiosInstance } from "./axiosInstance";
import { ApiToken } from "./types";
const API_INVALID_SHOP_ID_CODE = 608;
/**
* Check whether the token of the user is valid.
*
* @param token the token of the logged in customer
* @returns whether the user's token is still valid for the backend (aka if he is still logged in)
*/
export async function isTokenValid(token: ApiToken): Promise<boolean>{
// There is no official way to check if the token is still valid or not,
// so after asking Daniele Comes, he said to make a call to retrieveOrders
// with shopId of -1 so that the DB wouldn't be queried.
// If the answer is "Invalid field 'shopId'", then the token is valid.
// If it isn't, it will be picked up before checking the shopId and
// the answer will contain a certain variation of the message "Token is not valid"
// Daniele Comes said he asked the WOnD team to make an endpoint to ask if the token is valid.
let res = await axiosInstance.get("/retrieveOrders", { params: {
token,
shopId: -1
}})
if (res.data.retrieveOrders.result != "ERROR")
throw new Error("The API somehow didn't answer ERROR when asking to retrieve orders from shop with id=-1... This is not normal!");
const errorCode: number = res.data.retrieveOrders.errorCode;
return errorCode == API_INVALID_SHOP_ID_CODE;
// return false;
}

22
src/api/login.ts Normal file
View File

@ -0,0 +1,22 @@
import { axiosInstance } from "./axiosInstance";
import { ApiToken, LoginResponse, FailedLoginError } from "./types";
/**
*
* @param user the name of user logging in (if it's a customer, then it's his email)
* @param password the password of the user
* @returns
*/
export async function login(user: string, password: string): Promise<LoginResponse>{
const res = await axiosInstance.get("/login", { params: { user, password } });
if (res.data.login.result == "ERROR") {
const errCode: number = res.data.login.errorCode;
const errMsg: string = res.data.login.message;
throw new FailedLoginError(errMsg, errCode);
}
const token: ApiToken = res.data.login.customerProperties.token;
const id: number = res.data.login.customerProperties.id;
return { token, id };
}
export { FailedLoginError };

1
src/api/types/ApiToken.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export type ApiToken = string;

4
src/api/types/CustomerInfo.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export type CustomerInfo = {
name: string;
balance: number;
};

View File

@ -0,0 +1,8 @@
export class FailedLoginError extends Error {
code: number;
constructor(msg: string, code: number) {
super(msg);
this.code = code;
Object.setPrototypeOf(this, FailedLoginError.prototype);
}
}

6
src/api/types/LoginResponse.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
import { ApiToken } from "./ApiToken";
export type LoginResponse = {
token: ApiToken;
id: number;
};

4
src/api/types/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./ApiToken.d"
export * from "./CustomerInfo.d"
export * from "./LoginResponse.d"
export * from "./FailedLoginError"

View File

@ -6,7 +6,7 @@ type ErrorProps = {
err?: Error err?: Error
} }
export default function ErrorPage({err} : ErrorProps) { export function ErrorPage({err} : ErrorProps) {
const error: any = err || useRouteError(); const error: any = err || useRouteError();
console.error(error); console.error(error);

View File

@ -22,7 +22,7 @@ const schema = yup
}) })
.required(); .required();
export default function ForgotPassword() { export function ForgotPassword() {
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
register, register,

View File

@ -1,4 +1,4 @@
import React, { useContext } from "react"; import React, { useContext, useEffect } from "react";
import { Card, Button, TextField } from "@mui/material"; import { Card, Button, TextField } from "@mui/material";
import "@scss/login.scss"; import "@scss/login.scss";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@ -7,6 +7,9 @@ import * as yup from "yup";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import userContext from "@ts/userContext" import userContext from "@ts/userContext"
import Typography from "@theme/modules/components/Typography"; import Typography from "@theme/modules/components/Typography";
import { FailedLoginError } from "@api/types/FailedLoginError";
import { LoginResponse } from "@api/types";
import { login } from "@api";
type Inputs = { type Inputs = {
email: string; email: string;
@ -24,9 +27,15 @@ const schema = yup
}) })
.required(); .required();
export default function Login() { export function Login() {
const user = useContext(userContext); const user = useContext(userContext);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
if (user.token)
navigate("/");
}, [user.token])
const { const {
register, register,
formState: { errors }, formState: { errors },
@ -35,10 +44,6 @@ export default function Login() {
resolver: yupResolver(schema), resolver: yupResolver(schema),
}); });
function hash(s: string) {
return `hash of '${s}' to be definied`;
}
const { ref: emailRef, ...emailRegisterProps } = register("email"); const { ref: emailRef, ...emailRegisterProps } = register("email");
const emailProps = { const emailProps = {
@ -61,16 +66,29 @@ export default function Login() {
name: "password", name: "password",
id: "password", id: "password",
label: "Password", label: "Password",
type: "password",
error: !!errors.password, error: !!errors.password,
helperText: errors?.password?.message, helperText: errors?.password?.message,
}; };
function onSubmit(data: Inputs) { async function onSubmit(data: Inputs) {
console.warn("There will be an API call now with the following data"); console.warn("There will be an API call now with the following data");
data = { ...data, password: hash(data.password) };
user.setName(data.email);
console.table(data); console.table(data);
setTimeout(() => navigate("/?logged"), 3000); try {
const res: LoginResponse = await login(data.email, data.password);
console.log("Response of login:", res);
user.setToken(res.token);
user.setId(res.id);
console.log("User context after login:", user)
window.localStorage.setItem("token", res.token); // this could be done with a hook (right?)
window.localStorage.setItem("customerId", res.id.toString()); // this could be done with a hook (right?)
}catch(err){
if (err instanceof FailedLoginError)
alert(`Failed to login user.\nError code: '${err.code}', error message: '${err.message}'\n\nYes, I know I should handle it with something else than an alert, but for now I'm making the thing work When it fully works, I'll make it prettier :)`)
else
alert(`Failed to make api call\nError message: ${err.message}`);
}
} }
return ( return (

View File

@ -22,7 +22,7 @@ const schema = yup
}) })
.required(); .required();
export default function Reset() { export function Reset() {
const navigate = useNavigate(); const navigate = useNavigate();
const { const {

View File

@ -1,11 +1,12 @@
import React, { useContext, useEffect } from "react"; import React, { useContext, useEffect, useState, MouseEvent } from "react";
import "@scss/transactions.scss"; import "@scss/transactions.scss";
import { ThreeDots as LoadingIcon } from "react-loading-icons";
import Table from "@components/Transactions/Table" import Table from "@components/Transactions/Table"
import userContext from "@ts/userContext" import userContext from "@ts/userContext"
import AppBar from "@theme/modules/components/AppBar"; import AppBar from "@theme/modules/components/AppBar";
import Toolbar from "@theme/modules/components/Toolbar"; import Toolbar from "@theme/modules/components/Toolbar";
import Typography from "@theme/modules/components/Typography"; import Typography from "@theme/modules/components/Typography";
import { Box, Link, TextFieldProps, TextField } from "@mui/material"; import { Box, Link, TextFieldProps, TextField, Button } from "@mui/material";
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker'; import { DesktopDatePicker } from '@mui/x-date-pickers/DesktopDatePicker';
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -15,6 +16,8 @@ import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider/L
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { getValue } from "@mui/system"; import { getValue } from "@mui/system";
import { CustomerInfo } from "@api/types";
import { useGetCustomerInfo } from "src/hooks/useGetCustomerInfo";
@ -30,7 +33,7 @@ type Inputs = {
const schema = yup const schema = yup
.object() .object()
.shape({ .shape({
startDate: yup.date().nullable().default(() => {return null}).typeError("Invalid date"), startDate: yup.date().nullable().typeError("Invalid date"),
endDate: yup.date().nullable().typeError("Invalid date"), endDate: yup.date().nullable().typeError("Invalid date"),
minAmount: yup.number().positive(), minAmount: yup.number().positive(),
maxAmount: yup.number().positive(), maxAmount: yup.number().positive(),
@ -39,8 +42,14 @@ const schema = yup
.required(); .required();
export default function Transactions() { export function Transactions() {
const user = useContext(userContext) const user = useContext(userContext);
const { customerInfo, isLoading, getCustomerInfo } = useGetCustomerInfo();
useEffect(() => {
if (user.token)
getCustomerInfo(user.token, user.id);
}, [user.token])
const { const {
register, register,
@ -53,7 +62,7 @@ export default function Transactions() {
const { ref: startDateRef, ...startDateRegisterProps } = register("startDate"); const { ref: startDateRef, ...startDateRegisterProps } = register("startDate");
console.log(startDateRegisterProps) // console.log(startDateRegisterProps)
const startDatePickerProps = { const startDatePickerProps = {
...startDateRegisterProps, ...startDateRegisterProps,
@ -106,6 +115,10 @@ export default function Transactions() {
helperText: errors?.maxAmount?.message, helperText: errors?.maxAmount?.message,
} }
function handleRefreshButton(event: MouseEvent<HTMLElement>): void {
getCustomerInfo(user.token, user.id);
}
return ( return (
<div id="tablePage"> <div id="tablePage">
<AppBar position="fixed" sx={{ background: "#28282a" }}> <AppBar position="fixed" sx={{ background: "#28282a" }}>
@ -114,9 +127,13 @@ export default function Transactions() {
<Typography variant="h3" > <Typography variant="h3" >
Transactions Transactions
</Typography> </Typography>
<Button sx={{marginLeft: 5}} variant="contained" onClick={handleRefreshButton}>
Refresh
</Button>
<Box className="box right"> <Box className="box right">
<Link variant="h6" className="link" href="/"> <Link variant="h6" className="link userName" href="/">
{user.name} {isLoading ? <LoadingIcon height=".5em"/> : customerInfo.name}
{/* {customerInfo.name} (CHF {customerInfo.balancE}) */}
</Link> </Link>
<AccountCircleIcon className="icon" /> <AccountCircleIcon className="icon" />
</Box> </Box>
@ -127,18 +144,26 @@ export default function Transactions() {
<Typography variant="h5"> <Typography variant="h5">
Filters Filters
</Typography> </Typography>
<Box className="box">
<Typography variant="h6"> <Typography variant="h6">
Dates Dates
</Typography> </Typography>
<Box className="inputs-box dates">
<LocalizationProvider dateAdapter={AdapterDayjs}> <LocalizationProvider dateAdapter={AdapterDayjs}>
<DesktopDatePicker {...startDatePickerProps} /> <DesktopDatePicker {...startDatePickerProps} />
<DesktopDatePicker {...endDatePickerProps} /> <DesktopDatePicker {...endDatePickerProps} />
</LocalizationProvider> </LocalizationProvider>
</Box>
</Box>
<Box className="box">
<Typography variant="h6"> <Typography variant="h6">
Transactions Transactions
</Typography> </Typography>
<Box className="inputs-box transactions">
<TextField {...minAmountProps}></TextField> <TextField {...minAmountProps}></TextField>
<TextField {...maxAmountProps}></TextField> <TextField {...maxAmountProps}></TextField>
</Box>
</Box>
</form> </form>
</aside> </aside>
<main> <main>

View File

@ -49,9 +49,9 @@ export default () => {
<TableCell component="th" scope="row"> <TableCell component="th" scope="row">
{row.date.toLocaleDateString()} {row.date.toLocaleDateString()}
</TableCell> </TableCell>
<TableCell>{row.description}</TableCell> <TableCell className="description">{row.description}</TableCell>
<TableCell align="right" className={"amount " + (row.amount < 0 ? "negative" : "positive")}>{CHF_currency(row.amount)}</TableCell> <TableCell align="right" className={"amount " + (row.amount < 0 ? "negative" : "positive")}>{CHF_currency(row.amount)}</TableCell>
<TableCell align="right">{CHF_currency(row.total)}</TableCell> <TableCell align="right" className="total">{CHF_currency(row.total)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@ -1,9 +1,7 @@
import Login from "@components/Login"; export * from "@components/Login";
import ForgotPassword from "@components/ForgotPassword"; export * from "@components/ForgotPassword";
import Reset from "@components/Reset"; export * from "@components/Reset";
import ErrorPage from "@components/ErrorPage"; export * from "@components/ErrorPage";
import Transactions from "@components/Transactions"; export * from "@components/Transactions";
import MetadataSetter from "@components/lib/MetadataSetter"; export * from "@components/lib/MetadataSetter";
import AuthComponent from "@components/lib/AuthComponent"; export * from "@components/lib/AuthComponent";
export { Login, ForgotPassword, Reset, ErrorPage, Transactions, MetadataSetter, AuthComponent };

View File

@ -1,23 +1,34 @@
import ErrorPage from "@components/ErrorPage"; import { isTokenValid } from "@api";
import React, { useEffect } from "react"; import { ApiToken } from "@api/types";
import userContext from "@ts/userContext";
import React, { useContext, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
type AuthProps = { type AuthProps = {
children: React.ReactNode, children: React.ReactNode,
} }
export default function AuthComponent({ children }: AuthProps) { export function AuthComponent({ children }: AuthProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const searchParams = useSearchParams()[0]; const user = useContext(userContext);
const isLogged = searchParams.has("logged");
async function checkIfCustomerLogged(token: ApiToken) {
const isLogged: boolean = await isTokenValid(user.token);
if (!isLogged) {
user.setToken(null);
user.setId(null);
window.localStorage.removeItem("token");
window.localStorage.removeItem("customerId");
}
}
useEffect(() => { useEffect(() => {
// navigate needs to be wrapped in a useEffect so that it gets executed after the component is mounted. Otherwise it doesn't redirect if (!user.token)
if (!isLogged)
navigate("/login", { replace: true }); navigate("/login", { replace: true });
}, [isLogged]) // navigate needs to be wrapped in a useEffect so that it gets executed after the component is mounted. Otherwise it doesn't redirect
checkIfCustomerLogged(user.token);
}, [user.token])
if (isLogged) // by default return the children, the effect will check if the user needs to be redirected
return <>{children}</>; return <>{children}</>;
// else
// return <ErrorPage err={new Error("You are not logged in and you should have been redirected to the login page. Something went wrong.")}/>;
} }

View File

@ -8,7 +8,8 @@ type MetadataProps = {
needsAuth?: boolean needsAuth?: boolean
} }
export default function MetadataSetter({children, title, bodyClass, needsAuth = false}: MetadataProps){ export function MetadataSetter({ children, title, bodyClass, needsAuth = false }: MetadataProps) {
useEffect(() => { useEffect(() => {
document.title = title; document.title = title;
document.body.classList.add(bodyClass); document.body.classList.add(bodyClass);

View File

@ -0,0 +1,17 @@
import { fetchCustomerInfo } from "@api";
import { ApiToken, CustomerInfo } from "@api/types";
import { useState } from "react";
export function useGetCustomerInfo() {
const [customerInfo, setCustomerInfo] = useState<CustomerInfo>({ name: "John Smith", balance: -1 });
const [isLoading, setIsLoading] = useState<boolean>(true); // this should be false, but at least the "Loading..." text appears when the page is loading
const getCustomerInfo = (token: ApiToken, id: number) => {
setIsLoading(true);
fetchCustomerInfo(token, id)
.then(setCustomerInfo)
.then(() => setIsLoading(false));
}
return { isLoading, customerInfo, getCustomerInfo };
}

View File

@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { MetadataSetter, ErrorPage, Login, ForgotPassword, Reset, Transactions } from "@components"; import { MetadataSetter, ErrorPage, Login, ForgotPassword, Reset, Transactions } from "@components";
import UserContext from '@ts/userContext' import UserContext from '@ts/userContext'
import { ApiToken } from "@api/types";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -41,8 +42,11 @@ const router = createBrowserRouter([
const App = function () { const App = function () {
const [name, setName] = useState("John Smith")
const userContextValue = { name, setName } const [token, setToken] = useState<ApiToken>(window.localStorage.getItem("token"));
const [id, setId] = useState<number>(Number.parseInt(window.localStorage.getItem("customerId")));
const userContextValue = { token, setToken, id, setId }
return ( return (
<UserContext.Provider value={userContextValue}> <UserContext.Provider value={userContextValue}>
<RouterProvider router={router} /> <RouterProvider router={router} />

View File

@ -1,6 +1,7 @@
@use "style.scss"; @use "style.scss";
@import "variables.module.scss"; @import "variables.module.scss";
body.transactions { body.transactions {
background-image: url("/public/backgroundCurvyLines.png"); background-image: url("/public/backgroundCurvyLines.png");
@ -39,6 +40,7 @@ body.transactions {
align-items: center; align-items: center;
.link { .link {
text-align: right;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
margin-right: .5em; margin-right: .5em;
@ -67,6 +69,17 @@ body.transactions {
// justify-content: center; // justify-content: center;
align-items: center; align-items: center;
.description {
text-overflow: ellipsis;
white-space: nowrap;
// overflow: hidden;
}
.amount,
.total {
min-width: 6em;
}
.amount.positive { .amount.positive {
color: $fg-green; color: $fg-green;
} }
@ -79,6 +92,7 @@ body.transactions {
aside { aside {
grid-column: 1; grid-column: 1;
grid-row: 2; grid-row: 2;
min-width: 173px;
form { form {
display: flex; display: flex;
@ -95,4 +109,69 @@ body.transactions {
} }
} }
} }
@media only screen and (max-width: 1044px) {
div#tablePage {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr 10fr 1fr;
header,
main,
aside,
footer {
grid-column: 1;
}
header {
grid-row: 1;
position: sticky;
.box.left {
display: none;
}
}
aside {
grid-row: 2;
min-width: 0;
form {
flex-direction: row;
h3 {
display: none;
}
h2 {
text-align: center;
}
.box {
flex-grow: 1;
.inputs-box {
// background: red;
display: flex;
flex-direction: row;
justify-content: space-evenly;
.input {
// background: blue;
width: 10em;
}
}
}
}
}
main {
grid-row: 3;
}
footer {
grid-row: 4;
}
}
}
} }

View File

@ -1,6 +1,8 @@
import { createContext } from 'react' import { createContext } from 'react'
export default createContext({ export default createContext({
name : "Unknown", token : "Unknown",
setName: (name: string) => {} setToken: (newToken: string) => {},
id : -1,
setId: (newId: number) => {},
}) })

View File

@ -14,6 +14,8 @@
"@components/*": ["src/components/*"], "@components/*": ["src/components/*"],
"@scss/*": ["src/scss/*"], "@scss/*": ["src/scss/*"],
"@ts/*": ["src/ts/*"], "@ts/*": ["src/ts/*"],
"@api": ["src/api"],
"@api/*": ["src/api/*"],
"@theme/*": ["themes/onepirate/*"], "@theme/*": ["themes/onepirate/*"],
} }
}, },

View File

@ -25,9 +25,13 @@ module.exports = (env, argv) => {
module: { module: {
rules: [ rules: [
{ {
test: /\.tsx?/, test: /\.tsx?$/,
use: 'ts-loader', loader: 'ts-loader',
exclude: /node_modules/ exclude: /node_modules|\.d\.ts$/
},
{
test: /\.d\.ts$/,
loader: 'ignore-loader'
}, },
{ {
test: /\.jsx?$/, test: /\.jsx?$/,