Compare commits
13 Commits
466e005e4e
...
develop-pr
Author | SHA1 | Date | |
---|---|---|---|
d762a8f70f | |||
257642a251 | |||
dc7f4b25a9 | |||
a463189052 | |||
dbba262044 | |||
23d957824b | |||
2bd5566d6a | |||
d2368b30e5 | |||
49266bbc97 | |||
f6a151337a | |||
ce826bd8db | |||
fe927b44ad | |||
bf3923b4b9 |
1
.gitignore
vendored
@ -21,3 +21,4 @@ expo-env.d.ts
|
||||
android
|
||||
builds
|
||||
.env
|
||||
PliWould.keystore
|
||||
|
107
README.md
@ -1,5 +1,7 @@
|
||||
# PliWould - Measure And Price Sheet Good Merchandise
|
||||
|
||||

|
||||
|
||||
Working behind the register at Habitat for Humanity ReStore I found sheet goods often came in partials.
|
||||
|
||||
As a cashier, I would often have to determine the correct price for merchandise based on the area or length
|
||||
@ -10,60 +12,87 @@ a manager to assist. Instead, I took it upon myself to find a solution.
|
||||
|
||||
Hence, I created PliWould, an app that determines the cost of a product based on its dimensions.
|
||||
|
||||
By default my store's prices are in the database. However, the prices are editable and you can add or remove
|
||||
By default my store's prices are in the database.
|
||||
However, the prices are editable and you can add or remove
|
||||
products as needed.
|
||||
|
||||
## Usage
|
||||
|
||||
[[ TODO ]]
|
||||

|
||||
|
||||
###  Measure Tab
|
||||
|
||||
This tab is used to determine the price based on measurements.
|
||||
|
||||
Select a product from a list of products.
|
||||
|
||||

|
||||
|
||||
There are 2 different types of products:
|
||||
|
||||
1. Length prodcuts
|
||||
2. Area products.
|
||||
|
||||
Typically length products have a Square button that measures per length,
|
||||
but some do not, so I included them.
|
||||
|
||||
Area products on the Square console are "partials," so they are listed in the product list.
|
||||
|
||||
Select one you wish to price.
|
||||
|
||||
Automatically the measurements for the "base measurement" will be filled in.
|
||||
|
||||
Using a tape measure, measure the sheet's length and width. You can either put in
|
||||
inches or feet (switch using the `in`/`ft` button selector).
|
||||
|
||||
If the product is damaged, use the slider to select the amount of damage
|
||||
|
||||

|
||||
|
||||
If you select a length product, only the length field will be present. Proceed as you
|
||||
would with area products.
|
||||
|
||||

|
||||
|
||||
###  Product Editor (WIP)
|
||||
|
||||
In the product editor, you can add or remove products as needed.
|
||||
|
||||
You can even edit or add attributes.
|
||||
|
||||
Note that the `name` attribute is highly recommended as it's the name of the product.
|
||||
|
||||
Otherwise, it will display as `Product <UUID>`.
|
||||
|
||||
# Development Docs
|
||||
|
||||
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||
This is an [Expo](https://expo.dev) project.
|
||||
|
||||
The `develop` branch is used to develop features until it's ready to be merged
|
||||
into main.
|
||||
|
||||
## Get started
|
||||
|
||||
1. Clone the repository.
|
||||
|
||||
```
|
||||
$ git clone https://gittea.dev/srcrr/PliWould
|
||||
```
|
||||
|
||||
2. Install eas-cli **globally**
|
||||
|
||||
```
|
||||
$ npm i -g eas-cli
|
||||
```
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
## Get a fresh project
|
||||
|
||||
When you're ready, run:
|
||||
|
||||
```bash
|
||||
npm run reset-project
|
||||
```
|
||||
|
||||
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||
|
||||
## Learn more
|
||||
|
||||
To learn more about developing your project with Expo, look at the following resources:
|
||||
|
||||
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||
|
||||
## Join the community
|
||||
|
||||
Join our community of developers creating universal apps.
|
||||
|
||||
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||
pnpx expo start
|
||||
```
|
@ -1,21 +1,113 @@
|
||||
import { Product } from "@/lib/product";
|
||||
import { Product } from "@/lib/product"
|
||||
import uuid from "react-native-uuid"
|
||||
|
||||
export const products = [
|
||||
// Sheet goods
|
||||
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/4\"" }),
|
||||
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "Plywood 1/2\"" }),
|
||||
new Product(25, {l: 4, w : 8, u: "ft"}, { name: "Plywood 3/4\"" }),
|
||||
new Product(5, {l: 4, w : 8, u: "ft"}, { name: "Thin Panel Board" }),
|
||||
new Product(10, {l: 4, w : 8, u: "ft"}, { name: "Sheetrock" }),
|
||||
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "OSB / Particle" }),
|
||||
new Product(20, {l: 4, w : 8, u: "ft"}, { name: "MDF" }),
|
||||
new Product(15, {l: 4, w : 8, u: "ft"}, { name: "Pegboard" }),
|
||||
new Product(5, {l: 3, w : 5, u: "ft"}, { name: "Cement" }),
|
||||
// trim
|
||||
new Product(1, {l: 0.50, u : "ft"}, { name: "trim <= 3 inches" }),
|
||||
new Product(1, {l: 0.75, u : "ft"}, { name: "trim > 3 inches" }),
|
||||
// siding
|
||||
new Product(1, {l: 1, u: "ft"}, {name: "house siding"}),
|
||||
new Product(1, {l: 1, u: "ft"}, {name: "metal / shelf bars"}),
|
||||
new Product(0.5, {l: 1, u: "ft"}, {name: "gutter spouts"}),
|
||||
];
|
||||
export default [
|
||||
// Sheet goods
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 15,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: 'Plywood 1/4"' },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 20,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: 'Plywood 1/2"' },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 25,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: 'Plywood 3/4"' },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 5,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "Thin Panel Board" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 10,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "Sheetrock" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 15,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "OSB / Particle" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 20,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "MDF" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 15,
|
||||
dimensions: { l: 4, w: 8, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "Pegboard" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 5,
|
||||
dimensions: { l: 3, w: 5, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "Cement" },
|
||||
},
|
||||
// trim
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 1,
|
||||
dimensions: { l: 0.5, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "trim <=3 inches" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 1,
|
||||
dimensions: { l: 0.75, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "trim > 3 inches" },
|
||||
},
|
||||
// siding
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 1,
|
||||
dimensions: { l: 1, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "house siding" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 1,
|
||||
dimensions: { l: 1, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "metal / shelf bars" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 0.5,
|
||||
dimensions: { l: 1, u: "ft" },
|
||||
type: "lumber",
|
||||
attributes: { name: "gutter spouts" },
|
||||
},
|
||||
{
|
||||
id: uuid.v4().valueOf(),
|
||||
pricePerUnit: 0.75,
|
||||
dimensions: { l: 1, w: 1, u: "ft" },
|
||||
type: "area_rug",
|
||||
attributes: { name: "area rug" },
|
||||
},
|
||||
] as Array<Product>;
|
11
app.json
@ -1,12 +1,12 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "PliWould",
|
||||
"slug": "PliWould",
|
||||
"slug": "pliwould",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "pliwould",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
@ -20,7 +20,7 @@
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "tech.damngood.PliWould"
|
||||
"package": "tech.damngood.pliwould",
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -38,8 +38,9 @@
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "bf9125c3-72d0-42a7-9480-74c4717e7ed3"
|
||||
"projectId": "113390d8-ca95-42f1-bd03-577b83487f7c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"owner": "damngoodtech"
|
||||
}
|
||||
}
|
||||
|
@ -1,39 +1,66 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Tabs } from "expo-router";
|
||||
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
|
||||
import { Provider } from 'react-redux';
|
||||
import { products as fixtures } from "@/__fixtures__/initialProducts"
|
||||
import { setupStore } from '../store';
|
||||
import { SvgUri } from "react-native-svg";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||
import { Provider } from "react-redux";
|
||||
import fixtures from "@/__fixtures__/initialProducts";
|
||||
import { setupStore } from "../store";
|
||||
|
||||
const CARPET_ROLL_SVG = "/assets/images/icons/icon-carpet-roll-raw.svg";
|
||||
const CARPET_ROLL_SELECTED_SVG =
|
||||
"/assets/images/icons/icon-carpet-roll-selected-raw.svg";
|
||||
|
||||
const CarpetRollIcon = ({ selected }: { selected: boolean }) => {
|
||||
const uri = selected ? CARPET_ROLL_SELECTED_SVG : CARPET_ROLL_SVG;
|
||||
console.log(`Loading %s`, uri);
|
||||
return <SvgUri width="2em" height="2em" uri={uri} />;
|
||||
};
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const store = setupStore({
|
||||
products: fixtures.map(p => p.asObject)
|
||||
products: fixtures,
|
||||
});
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
||||
headerShown: false,
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home Screen',
|
||||
title: "Plywood",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? 'scale' : 'scale-outline'} color={color} />
|
||||
<TabBarIcon
|
||||
name={focused ? "scale" : "scale-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="carpet-roll-calculator"
|
||||
options={{
|
||||
title: "Carpet Roll Calculator",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<CarpetRollIcon selected={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="product-editor"
|
||||
options={{
|
||||
title: 'Products',
|
||||
title: "Edit Products",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? 'list' : 'list-outline'} color={color} />
|
||||
<TabBarIcon
|
||||
name={focused ? "list" : "list-outline"}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
10
app/(tabs)/carpet-roll-calculator.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import CarpetRollCalculator from '@/components/CarpetRollCalculator';
|
||||
import { View } from 'react-native';
|
||||
|
||||
export default function CarpetRollCalculatorView () {
|
||||
return (
|
||||
<View>
|
||||
<CarpetRollCalculator />
|
||||
</View>
|
||||
)
|
||||
}
|
@ -1,11 +1,19 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import "react-native-reanimated";
|
||||
import "react-native-gesture-handler";
|
||||
import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
} from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useColorScheme } from "@/hooks/useColorScheme";
|
||||
import { Appearance, StyleSheet, Text, Pressable } from "react-native";
|
||||
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@ -13,9 +21,18 @@ SplashScreen.preventAutoHideAsync();
|
||||
export default function RootLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
||||
});
|
||||
|
||||
function changeTheme() {
|
||||
console.debug("Changing color scheme");
|
||||
if (Appearance.getColorScheme() === "dark") {
|
||||
Appearance.setColorScheme("light");
|
||||
} else {
|
||||
Appearance.setColorScheme("dark");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
@ -27,11 +44,11 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
}
|
23
app/app.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { LinkingOptions, NavigationContainer } from "@react-navigation/native";
|
||||
import { Text, View } from "react-native";
|
||||
import * as Linking from "expo-linking";
|
||||
import Root from "./+html";
|
||||
|
||||
const prefix = Linking.createURL("/");
|
||||
|
||||
const linking: LinkingOptions<any> = {
|
||||
prefixes: [
|
||||
"myapp://", prefix,
|
||||
],
|
||||
config: {
|
||||
screens: {}
|
||||
},
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<NavigationContainer linking={linking} fallback={<Text>Loading...</Text>}>
|
||||
<Root />
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
70
app/store.ts
@ -1,35 +1,57 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { rememberReducer, rememberEnhancer } from 'redux-remember';
|
||||
import reducers from "@/features/product/productSlice"
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Product, ProductData, } from "@/lib/product";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { rememberReducer, rememberEnhancer } from "redux-remember";
|
||||
import reducers, { DEFAULT_PRELOADED_STATE } from "@/features/product/productSlice";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const rememberedKeys = ['products'];
|
||||
|
||||
// thanks to https://github.com/rt2zz/redux-persist/issues/1208#issuecomment-658695446
|
||||
// for the workaround
|
||||
const createNoopStorage = () => {
|
||||
return {
|
||||
getItem(_key : any) {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
setItem(_key : any, value : any) {
|
||||
return Promise.resolve(value);
|
||||
},
|
||||
removeItem(_key : any) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const storage =
|
||||
typeof window === "undefined" ? createNoopStorage() : AsyncStorage;
|
||||
|
||||
export default storage;
|
||||
|
||||
const PERSIST_WHOLE_STORE =
|
||||
new Boolean(process.env.PERSIST_WHOLE_STORE).valueOf() || false;
|
||||
|
||||
const rememberedKeys = ["products"];
|
||||
|
||||
const rootReducer = reducers;
|
||||
|
||||
export function setupStore(preloadedState = {
|
||||
products: [] as ProductData[],
|
||||
}) {
|
||||
return configureStore({
|
||||
reducer: rememberReducer(reducers),
|
||||
preloadedState,
|
||||
enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat(
|
||||
rememberEnhancer(
|
||||
AsyncStorage,
|
||||
rememberedKeys,
|
||||
{
|
||||
persistWholeStore: true,
|
||||
}
|
||||
)
|
||||
),
|
||||
});
|
||||
// const isBrowser = typeof window !== "undefined";
|
||||
|
||||
|
||||
export function setupStore(preloadedState = DEFAULT_PRELOADED_STATE) {
|
||||
return configureStore({
|
||||
reducer: rememberReducer(reducers),
|
||||
preloadedState,
|
||||
enhancers: (getDefaultEnhancers) =>
|
||||
getDefaultEnhancers().concat(
|
||||
rememberEnhancer(storage, rememberedKeys, {
|
||||
persistWholeStore: PERSIST_WHOLE_STORE,
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
export type AppStore = ReturnType<typeof setupStore>;
|
||||
export type AppDispatch = AppStore['dispatch'];
|
||||
export type AppDispatch = AppStore["dispatch"];
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
BIN
assets/images/icons/carpet-roll-64.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-45.575391,42.027803)">
|
||||
<path
|
||||
style="fill:none;fill-rule:evenodd;stroke:#666666;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2530"
|
||||
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:19.4423;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2584"
|
||||
cx="145.57539"
|
||||
cy="57.972198"
|
||||
r="37.746468" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
46
assets/images/icons/carpet-roll-length-inner-diameter.svg
Normal file
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="carpet-roll-length-inner-diameter.svg"
|
||||
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
|
||||
inkscape:export-xdpi="11.417511"
|
||||
inkscape:export-ydpi="11.417511"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.56504558"
|
||||
inkscape:cx="16.812803"
|
||||
inkscape:cy="315.90372"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1008"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="681"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-45.022935,37.596851)" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-45.575391,42.027803)">
|
||||
<path
|
||||
style="fill:none;fill-rule:evenodd;stroke:#666666;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2530"
|
||||
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2733"
|
||||
cx="98.956039"
|
||||
cy="63.796986"
|
||||
r="10.301529" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle2839"
|
||||
cx="77.884727"
|
||||
cy="63.796986"
|
||||
r="10.301529" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle2841"
|
||||
cx="56.813419"
|
||||
cy="63.796986"
|
||||
r="10.301529" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.0 KiB |
78
assets/images/icons/carpet-roll-length-number-of-rings.svg
Normal file
@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="carpet-roll-length-number-of-rings.svg"
|
||||
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
|
||||
inkscape:export-xdpi="11.417511"
|
||||
inkscape:export-ydpi="11.417511"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.56504558"
|
||||
inkscape:cx="15.043034"
|
||||
inkscape:cy="331.83164"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1008"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="681"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-45.575391,42.027803)">
|
||||
<path
|
||||
sodipodi:type="spiral"
|
||||
style="fill:none;fill-rule:evenodd;stroke:#666666;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2530"
|
||||
sodipodi:cx="377.95276"
|
||||
sodipodi:cy="377.95276"
|
||||
sodipodi:expansion="0.75"
|
||||
sodipodi:revolution="4.1178799"
|
||||
sodipodi:radius="386.66254"
|
||||
sodipodi:argument="-20.113815"
|
||||
sodipodi:t0="0.29170668"
|
||||
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2733"
|
||||
cx="98.956039"
|
||||
cy="63.796986"
|
||||
r="10.301529" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle2839"
|
||||
cx="77.884727"
|
||||
cy="63.796986"
|
||||
r="10.301529" />
|
||||
<circle
|
||||
style="fill:#ff0000;fill-opacity:1;stroke:none;stroke-width:12.5855;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle2841"
|
||||
cx="56.813419"
|
||||
cy="63.796986"
|
||||
r="10.301529" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-45.575391,42.027803)">
|
||||
<path
|
||||
style="fill:none;fill-rule:evenodd;stroke:#ff0000;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2530"
|
||||
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
60
assets/images/icons/carpet-roll-length-outer-diameter.svg
Normal file
@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="carpet-roll-length-outer-diameter.svg"
|
||||
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
|
||||
inkscape:export-xdpi="11.417511"
|
||||
inkscape:export-ydpi="11.417511"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.56504558"
|
||||
inkscape:cx="15.043034"
|
||||
inkscape:cy="331.83164"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1008"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="681"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-45.575391,42.027803)">
|
||||
<path
|
||||
sodipodi:type="spiral"
|
||||
style="fill:none;fill-rule:evenodd;stroke:#ff0000;stroke-width:40;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path2530"
|
||||
sodipodi:cx="377.95276"
|
||||
sodipodi:cy="377.95276"
|
||||
sodipodi:expansion="0.75"
|
||||
sodipodi:revolution="4.1178799"
|
||||
sodipodi:radius="386.66254"
|
||||
sodipodi:argument="-20.113815"
|
||||
sodipodi:t0="0.29170668"
|
||||
d="M 531.42926,377.9527 C 540.4661,468.89249 467.03058,547.48405 377.95283,554.81097 274.58289,563.31347 185.8308,479.52537 178.70389,377.95283 170.61282,262.6392 264.37977,164.07205 377.95267,157.12414 504.80741,149.3637 612.89142,252.78898 619.67878,377.95267 627.16514,516.00643 514.35942,633.34716 377.95286,639.98977 228.99642,647.24353 102.62843,525.3011 96.117079,377.95287 89.064387,218.35444 219.92743,83.163596 377.95264,76.771971 547.96095,69.895667 691.79092,209.48591 698.07274,377.95264 704.79242,558.16209 556.64955,710.46473 377.95289,716.64533 187.73159,723.22454 27.108112,566.68774 21.021235,377.95289 14.569103,177.89267 179.35459,9.0876696 377.95262,3.0879444 512.83571,-0.98692729 642.11816,69.980531 712.81242,184.62164"
|
||||
transform="matrix(0.26458333,0,0,0.26458333,50.423457,-37.133871)" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
47
assets/images/icons/carpet-roll-length-raw.svg
Normal file
@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-45.022935,37.596851)">
|
||||
<g
|
||||
id="g2358"
|
||||
transform="translate(9.9331103,33.633936)"
|
||||
style="stroke-width:1.000125;stroke-dasharray:none;stroke:#4d4d4d">
|
||||
<path
|
||||
id="path2242"
|
||||
style="fill:none;fill-opacity:1;stroke:#4d4d4d;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 61.405087,24.188362 95.383293,-52.476396 c 24.96471,-6.689275 40.50388,-5.439089 53.53212,6.973747 13.06695,12.4497209 16.56131,25.0292929 9.58226,53.29021 l -79.61121,74.744307">
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#4d4d4d;stroke-width:10.58333333;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 61.405087,24.188362 c 33.653,-20.0428233 86.037773,3.774406 93.118353,36.785838 7.08285,33.022041 -22.22503,60.93414 -48.54578,62.44 C 78.791137,124.96959 39.251217,95.823248 53.111058,61.74891 66.84398,27.986602 114.30936,21.282096 133.47145,61.067637 141.69618,78.144337 124.82498,115.7347 93.694884,101.95457 67.187624,90.220797 80.85949,55.619126 103.5068,72.272096"
|
||||
id="path2296" />
|
||||
</g>
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 54.956029,14.247787 v 49.11798"
|
||||
id="path2361" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:7.933;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 165.74785,-31.776353 V -4.1786544"
|
||||
id="path2363" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 54.956029,38.806777 165.74785,-17.977504"
|
||||
id="path2365" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 66.655684,60.631804 166.72149,5.3459019"
|
||||
id="path2419" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
80
assets/images/icons/carpet-roll-length.svg
Normal file
@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="200mm"
|
||||
height="200mm"
|
||||
viewBox="0 0 200 200"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="carpet-roll-length.svg"
|
||||
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
|
||||
inkscape:export-xdpi="11.417511"
|
||||
inkscape:export-ydpi="11.417511"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.56504558"
|
||||
inkscape:cx="16.812803"
|
||||
inkscape:cy="315.90372"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1008"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="681"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-45.022935,37.596851)">
|
||||
<g
|
||||
id="g2358"
|
||||
transform="translate(9.9331103,33.633936)"
|
||||
style="stroke-width:1.000125;stroke-dasharray:none">
|
||||
<path
|
||||
id="path2242"
|
||||
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 61.405087,24.188362 95.383293,-52.476396 c 24.96471,-6.689275 40.50388,-5.439089 53.53212,6.973747 13.06695,12.4497209 16.56131,25.0292929 9.58226,53.29021 l -79.61121,74.744307"
|
||||
sodipodi:nodetypes="ccscc" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:10.58333333;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 61.405087,24.188362 c 33.653,-20.0428233 86.037773,3.774406 93.118353,36.785838 7.08285,33.022041 -22.22503,60.93414 -48.54578,62.44 C 78.791137,124.96959 39.251217,95.823248 53.111058,61.74891 66.84398,27.986602 114.30936,21.282096 133.47145,61.067637 141.69618,78.144337 124.82498,115.7347 93.694884,101.95457 67.187624,90.220797 80.85949,55.619126 103.5068,72.272096"
|
||||
id="path2296"
|
||||
sodipodi:nodetypes="csssssc" />
|
||||
</g>
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 54.956029,14.247787 v 49.11798"
|
||||
id="path2361" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:7.933;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 165.74785,-31.776353 V -4.1786544"
|
||||
id="path2363" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 54.956029,38.806777 165.74785,-17.977504"
|
||||
id="path2365"
|
||||
sodipodi:nodetypes="cc" />
|
||||
<path
|
||||
style="fill:none;fill-opacity:1;stroke:#ff0000;stroke-width:10.5833;stroke-linecap:butt;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 66.655684,60.631804 166.72149,5.3459019"
|
||||
id="path2419"
|
||||
sodipodi:nodetypes="cc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
20
assets/images/icons/icon-carpet-roll-raw.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="138.30681mm"
|
||||
height="154.33244mm"
|
||||
viewBox="0 0 138.30681 154.33244"
|
||||
version="1.1"
|
||||
id="svg5">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-16.21663,9.4574458)">
|
||||
<path
|
||||
id="path1159"
|
||||
style="color:#000000;fill:#4d4d4d;fill-rule:evenodd;stroke:none;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||
d="M 84.740234,-9.4160156 C 68.15509,-8.7735841 52.02707,-1.7146219 39.964044,9.6231858 26.648596,22.071715 18.076319,39.496811 16.501795,57.66977 c -1.645753,18.579279 3.895027,37.618833 14.989336,52.58191 12.684228,17.27087 32.213572,29.40218 53.368996,32.91774 7.99486,1.38849 16.141663,0.90069 24.215283,1.12304 15.09416,0.23099 45.28397,0.58254 45.28397,0.58254 l 0.16406,-29.05664 c 0,0 -41.07257,-0.46602 -61.603333,-0.95016 C 73.52257,112.94115 55.60882,99.500597 48.574219,81.246094 44.440178,70.483349 44.205066,58.365847 48.518016,47.618972 c 3.162294,-8.025196 8.739489,-15.207303 15.794319,-20.25647 4.623193,-3.307847 9.913924,-5.744641 15.524849,-6.892099 8.672795,-1.790982 18.043304,-0.534421 25.815816,4.023536 7.87161,4.489385 14.18712,12.028138 16.56143,20.87735 1.23335,4.543838 1.36998,9.373431 0.30119,14.031055 -0.74507,3.332732 -2.25335,6.691651 -4.26758,9.552449 -3.31986,4.764731 -8.17204,8.525969 -13.70812,10.137984 -3.5817,1.063713 -7.364852,1.126483 -10.998725,0.157506 -3.130437,-0.828309 -6.176833,-2.547609 -8.469609,-4.970798 -0.828833,-0.825963 -1.718562,-1.752246 -1.90557,-2.953313 0.546353,0.03015 1.021774,0.492626 1.489295,0.77924 3.10825,2.815209 7.350333,4.248692 11.525155,4.113487 4.138474,-0.08494 8.323724,-1.110279 11.873164,-3.276351 5.61479,-3.665861 8.96726,-10.148139 9.71028,-16.711793 0.7865,-7.300809 -1.76907,-14.802261 -6.61867,-20.280517 C 105.81786,29.801689 97.945669,25.9565 89.819169,25.548534 80.084076,24.938498 70.258715,28.813652 63.340156,35.646519 c -8.084338,7.778492 -12.546172,19.129986 -11.962285,30.33033 0.439793,10.172436 4.701938,20.084949 11.660881,27.502481 9.452208,10.28192 23.498433,16.2062 37.471938,15.59438 12.72463,-0.43479 25.11785,-5.90199 34.24102,-14.742595 11.00086,-10.548759 17.60935,-25.606798 17.64012,-40.874184 0.14912,-15.553691 -6.1531,-30.978659 -16.8973,-42.187276 -12.0859,-12.8482542 -29.48065,-20.6217449 -47.150473,-20.717969 -1.20134,-0.016984 -2.403084,-0.012469 -3.603823,0.032298 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
15
assets/images/icons/icon-carpet-roll-selected-raw.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg
|
||||
width="138.30681mm"
|
||||
height="154.33244mm"
|
||||
viewBox="0 0 138.30681 154.33244">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-16.21663,9.4574458)">
|
||||
<path
|
||||
id="path1159"
|
||||
style="color:#000000;fill:#0a7ea4;fill-rule:evenodd;stroke:none;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
|
||||
d="M 84.740234,-9.4160156 C 68.15509,-8.7735841 52.02707,-1.7146219 39.964044,9.6231858 26.648596,22.071715 18.076319,39.496811 16.501795,57.66977 c -1.645753,18.579279 3.895027,37.618833 14.989336,52.58191 12.684228,17.27087 32.213572,29.40218 53.368996,32.91774 7.99486,1.38849 16.141663,0.90069 24.215283,1.12304 15.09416,0.23099 45.28397,0.58254 45.28397,0.58254 l 0.16406,-29.05664 c 0,0 -41.07257,-0.46602 -61.603333,-0.95016 C 73.52257,112.94115 55.60882,99.500597 48.574219,81.246094 44.440178,70.483349 44.205066,58.365847 48.518016,47.618972 c 3.162294,-8.025196 8.739489,-15.207303 15.794319,-20.25647 4.623193,-3.307847 9.913924,-5.744641 15.524849,-6.892099 8.672795,-1.790982 18.043304,-0.534421 25.815816,4.023536 7.87161,4.489385 14.18712,12.028138 16.56143,20.87735 1.23335,4.543838 1.36998,9.373431 0.30119,14.031055 -0.74507,3.332732 -2.25335,6.691651 -4.26758,9.552449 -3.31986,4.764731 -8.17204,8.525969 -13.70812,10.137984 -3.5817,1.063713 -7.364852,1.126483 -10.998725,0.157506 -3.130437,-0.828309 -6.176833,-2.547609 -8.469609,-4.970798 -0.828833,-0.825963 -1.718562,-1.752246 -1.90557,-2.953313 0.546353,0.03015 1.021774,0.492626 1.489295,0.77924 3.10825,2.815209 7.350333,4.248692 11.525155,4.113487 4.138474,-0.08494 8.323724,-1.110279 11.873164,-3.276351 5.61479,-3.665861 8.96726,-10.148139 9.71028,-16.711793 0.7865,-7.300809 -1.76907,-14.802261 -6.61867,-20.280517 C 105.81786,29.801689 97.945669,25.9565 89.819169,25.548534 80.084076,24.938498 70.258715,28.813652 63.340156,35.646519 c -8.084338,7.778492 -12.546172,19.129986 -11.962285,30.33033 0.439793,10.172436 4.701938,20.084949 11.660881,27.502481 9.452208,10.28192 23.498433,16.2062 37.471938,15.59438 12.72463,-0.43479 25.11785,-5.90199 34.24102,-14.742595 11.00086,-10.548759 17.60935,-25.606798 17.64012,-40.874184 0.14912,-15.553691 -6.1531,-30.978659 -16.8973,-42.187276 -12.0859,-12.8482542 -29.48065,-20.6217449 -47.150473,-20.717969 -1.20134,-0.016984 -2.403084,-0.012469 -3.603823,0.032298 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
17
assets/images/icons/icon-carpet-roll-selected.svg
Normal file
@ -0,0 +1,17 @@
|
||||
<svg
|
||||
width="142.37779mm"
|
||||
height="154.51445mm"
|
||||
viewBox="0 0 142.37779 154.51445">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-16.165914,9.5264309)">
|
||||
<path
|
||||
id="path1159"
|
||||
style="color:#000000;fill:#666666;fill-rule:evenodd;stroke:#cccccc;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 85.203125,-7.3242188 C 69.920576,-6.8219072 54.978371,-0.73159347 43.488281,9.3105469 29.730781,21.167938 20.62376,38.325311 18.755859,56.416016 c -1.734796,15.95712 1.938409,32.407948 10.191407,46.164064 10.334605,17.47423 27.516469,30.69674 46.990234,36.375 6.645281,1.95148 13.56116,3.1467 20.517578,3.0625 19.153082,0.25684 38.305822,0.51598 57.458982,0.77148 a 0.33113676,0.33113676 0 0 0 0.33399,-0.30664 c 0.64021,-8.0547 1.28097,-16.1094 1.92187,-24.16406 a 0.33113676,0.33113676 0 0 0 -0.32617,-0.35742 c -20.65485,-0.286 -41.30981,-0.51288 -61.960938,-0.86719 h -0.0039 c -13.133341,-1.00893 -25.787797,-7.10439 -35.0625,-16.4375 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 52.343603,94.184995 47.495562,86.16238 45.046875,77.314453 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 42.922997,69.749353 42.661672,61.651864 44.335938,54.025391 47.301902,40.407665 56.579386,28.264581 69.050781,22.005859 a 0.33113676,0.33113676 0 0 0 0.002,-0.002 c 7.218526,-3.661983 15.535683,-5.174225 23.513672,-4.125 a 0.33113676,0.33113676 0 0 0 0.002,0 c 12.749801,1.558443 24.475741,10.023864 29.830081,21.716797 1.528,3.328833 2.54287,7.075443 2.8457,10.787109 a 0.33113676,0.33113676 0 0 0 0,0.0039 c 0.50314,5.427095 -0.52104,11.148138 -3.02734,16.058593 a 0.33113676,0.33113676 0 0 0 -0.002,0.002 c -3.77296,7.555238 -10.91161,13.615485 -19.2832,15.21289 a 0.33113676,0.33113676 0 0 0 0,0.002 c -4.546338,0.882115 -9.418457,0.367934 -13.595702,-1.726562 a 0.33113676,0.33113676 0 0 0 -0.0039,-0.002 c -5.431125,-2.626837 -9.860013,-7.754966 -10.794922,-13.78711 -0.497265,-3.192735 0.282359,-6.536996 2.125,-9.185546 -0.0139,2.705038 0.257235,5.460018 1.404297,7.986328 1.724833,4.159718 5.336057,7.559506 9.757813,8.675781 3.964246,1.059122 8.116269,0.326271 11.890621,-0.886719 a 0.33113676,0.33113676 0 0 0 0.002,0 c 2.75294,-0.915515 5.13542,-2.694581 6.95508,-4.916016 l 0.004,-0.0059 c 4.22887,-4.970422 5.92559,-11.850563 4.76367,-18.238281 v -0.0039 c -1.39169,-8.320828 -7.31354,-15.475053 -14.83007,-19.078125 l -0.002,-0.002 C 92.910555,26.731743 83.676017,26.736335 75.808594,29.951172 64.535605,34.499437 56.212806,45.226465 54.117188,57.134766 c -1.968025,10.346436 0.517884,21.379932 6.552734,29.984375 7.700829,11.262849 20.653527,18.702169 34.257812,19.705079 11.668956,0.98802 23.584106,-2.53175 32.964846,-9.503908 l 0.002,-0.002 c 12.41469,-9.061773 20.69784,-23.548746 22.11133,-38.865234 v -0.002 c 1.33798,-13.328328 -2.27053,-27.040971 -9.87305,-38.054688 v -0.002 C 130.49093,6.1822132 114.86084,-3.7880615 97.898438,-6.5078125 93.705473,-7.195958 89.449462,-7.4542424 85.205078,-7.3242188 a 0.33113676,0.33113676 0 0 0 -0.002,0 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
52
assets/images/icons/icon-carpet-roll.svg
Normal file
@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="142.37779mm"
|
||||
height="154.51445mm"
|
||||
viewBox="0 0 142.37779 154.51445"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="icon-carpet-roll.svg"
|
||||
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
|
||||
inkscape:export-xdpi="11.417511"
|
||||
inkscape:export-ydpi="11.417511"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#585858"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.79909512"
|
||||
inkscape:cx="428.6098"
|
||||
inkscape:cy="198.34935"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1008"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="681"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-16.165914,9.5264309)">
|
||||
<path
|
||||
id="path1159"
|
||||
style="color:#000000;fill:#333333;fill-rule:evenodd;stroke:none;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 84.740234,-9.4160156 C 68.15509,-8.7735841 52.02707,-1.7146219 39.964044,9.6231858 26.648596,22.071715 18.076319,39.496811 16.501795,57.66977 c -1.645753,18.579279 3.895027,37.618833 14.989336,52.58191 12.684228,17.27087 32.213572,29.40218 53.368996,32.91774 7.99486,1.38849 16.141663,0.90069 24.215283,1.12304 15.09416,0.23099 45.28397,0.58254 45.28397,0.58254 l 0.16406,-29.05664 c 0,0 -41.07257,-0.46602 -61.603333,-0.95016 C 73.52257,112.94115 55.60882,99.500597 48.574219,81.246094 44.440178,70.483349 44.205066,58.365847 48.518016,47.618972 c 3.162294,-8.025196 8.739489,-15.207303 15.794319,-20.25647 4.623193,-3.307847 9.913924,-5.744641 15.524849,-6.892099 8.672795,-1.790982 18.043304,-0.534421 25.815816,4.023536 7.87161,4.489385 14.18712,12.028138 16.56143,20.87735 1.23335,4.543838 1.36998,9.373431 0.30119,14.031055 -0.74507,3.332732 -2.25335,6.691651 -4.26758,9.552449 -3.31986,4.764731 -8.17204,8.525969 -13.70812,10.137984 -3.5817,1.063713 -7.364852,1.126483 -10.998725,0.157506 -3.130437,-0.828309 -6.176833,-2.547609 -8.469609,-4.970798 -0.828833,-0.825963 -1.718562,-1.752246 -1.90557,-2.953313 0.546353,0.03015 1.021774,0.492626 1.489295,0.77924 3.10825,2.815209 7.350333,4.248692 11.525155,4.113487 4.138474,-0.08494 8.323724,-1.110279 11.873164,-3.276351 5.61479,-3.665861 8.96726,-10.148139 9.71028,-16.711793 0.7865,-7.300809 -1.76907,-14.802261 -6.61867,-20.280517 C 105.81786,29.801689 97.945669,25.9565 89.819169,25.548534 80.084076,24.938498 70.258715,28.813652 63.340156,35.646519 c -8.084338,7.778492 -12.546172,19.129986 -11.962285,30.33033 0.439793,10.172436 4.701938,20.084949 11.660881,27.502481 9.452208,10.28192 23.498433,16.2062 37.471938,15.59438 12.72463,-0.43479 25.11785,-5.90199 34.24102,-14.742595 11.00086,-10.548759 17.60935,-25.606798 17.64012,-40.874184 0.14912,-15.553691 -6.1531,-30.978659 -16.8973,-42.187276 -12.0859,-12.8482542 -29.48065,-20.6217449 -47.150473,-20.717969 -1.20134,-0.016984 -2.403084,-0.012469 -3.603823,0.032298 z"
|
||||
sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/images/pli-would-512.png
Normal file
After Width: | Height: | Size: 259 KiB |
@ -1,61 +1,78 @@
|
||||
import { MeasurementInput } from "./MeasurementInput";
|
||||
import { area_t, dimensions_t } from "@/lib/product";
|
||||
import { area_t, dimensions_t } from "@/lib/dimensions";
|
||||
import { Length } from "convert";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import MeasurementUnitInput from "./MeasurementUnitInput";
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store";
|
||||
import { selectPlywoodCalc } from "@/features/product/productSlice";
|
||||
|
||||
export type AreaInputProps = {
|
||||
onMeasurementSet?: (area : dimensions_t) => any,
|
||||
defaultValue?: area_t,
|
||||
lengthLabel?: string,
|
||||
widthLabel?: string,
|
||||
units?: Length,
|
||||
}
|
||||
|
||||
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue} : AreaInputProps) {
|
||||
export function AreaInput({onMeasurementSet, lengthLabel, widthLabel, defaultValue, units} : AreaInputProps) {
|
||||
|
||||
|
||||
defaultValue = defaultValue || {l: 0, w: 0, u: "ft"}
|
||||
units = units || "ft"
|
||||
|
||||
const [area, setArea] = useState(defaultValue)
|
||||
|
||||
function doOnLengthSet(measurement : dimensions_t) {
|
||||
setArea({
|
||||
...area,
|
||||
l: measurement.l
|
||||
});
|
||||
onMeasurementSet && onMeasurementSet({
|
||||
...area,
|
||||
l: measurement.l
|
||||
});
|
||||
function doOnLengthSet(l: number) {
|
||||
const a : area_t = { ...area, l };
|
||||
setArea(a);
|
||||
onMeasurementSet && onMeasurementSet(a);
|
||||
}
|
||||
|
||||
function doOnWidthSet(measurement : dimensions_t) {
|
||||
setArea({
|
||||
...area,
|
||||
w: measurement.l
|
||||
});
|
||||
onMeasurementSet && onMeasurementSet({
|
||||
...area,
|
||||
w: measurement.l
|
||||
});
|
||||
function doOnLengthUnitSet(u: Length) {
|
||||
const a : area_t = { ...area, u };
|
||||
setArea(a);
|
||||
onMeasurementSet && onMeasurementSet(a);
|
||||
}
|
||||
|
||||
function doOnWidthSet(l: number) {
|
||||
const a : area_t = { ...area, l };
|
||||
setArea(a);
|
||||
onMeasurementSet && onMeasurementSet(a);
|
||||
}
|
||||
|
||||
function doOnWidthUnitSet(u: Length) {
|
||||
const a : area_t = { ...area, u };
|
||||
setArea(a);
|
||||
onMeasurementSet && onMeasurementSet(a);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.areaInputWrapper}>
|
||||
<MeasurementInput
|
||||
defaultValue={{l: area.l, u: area.u}}
|
||||
<MeasurementUnitInput
|
||||
label="Length"
|
||||
defaultValue={0}
|
||||
defaultUnit={units}
|
||||
onValueSet={doOnLengthSet}
|
||||
label={lengthLabel}
|
||||
/>
|
||||
<MeasurementInput
|
||||
defaultValue={{l: area.w, u: area.u}}
|
||||
onUnitSet={doOnLengthUnitSet}
|
||||
aria-label="length"
|
||||
/>
|
||||
<Text style={{fontSize: 30,}} > x </Text>
|
||||
<MeasurementUnitInput
|
||||
label="Width"
|
||||
defaultValue={0}
|
||||
defaultUnit={units}
|
||||
onValueSet={doOnWidthSet}
|
||||
label={widthLabel}
|
||||
/>
|
||||
onUnitSet={doOnWidthUnitSet}
|
||||
aria-label="width"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
areaInputWrapper: {
|
||||
flexDirection: "row"
|
||||
flexDirection: "row",
|
||||
verticalAlign: "middle",
|
||||
}
|
||||
})
|
53
components/AreaRugTag.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { area_t } from "@/lib/dimensions";
|
||||
import { Product } from "@/lib/product";
|
||||
import convert, { Area, Length } from "convert";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
|
||||
export type AreaRugTagProps = {
|
||||
dimensions: area_t,
|
||||
product: Product,
|
||||
date?: Dayjs
|
||||
currencySymbol?: string
|
||||
};
|
||||
|
||||
export const AreaRugTag = (props: AreaRugTagProps) => {
|
||||
const date = props.date || dayjs();
|
||||
const square = props.dimensions.l * props.dimensions.w;
|
||||
const areaUnits = `sq ${props.dimensions.u}`;
|
||||
const square2 = convert(square, areaUnits as Area).to("sq " + props.product.dimensions.u as Area)
|
||||
const price = (square2 / props.product.pricePerUnit) * props.product.pricePerUnit;
|
||||
const sPrice = price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
const currencySymbol = props.currencySymbol || "$";
|
||||
return (
|
||||
<View aria-label="area rug tag" style={styles.component}>
|
||||
<Text aria-label="area rug dimensions" style={styles.dimensions}>{Math.round(props.dimensions.l)} x {Math.round(props.dimensions.w)}</Text>
|
||||
<Text aria-label="area rug price" style={styles.price}>{currencySymbol} {sPrice}</Text>
|
||||
<Text aria-label="area rug date" style={styles.date}>{date.format("YYYY/MM/DD")}</Text>
|
||||
<Text aria-label="this week's color" style={styles.tagColor}>[Curent Tag Color]</Text>
|
||||
</View>
|
||||
)
|
||||
};
|
||||
|
||||
const BIG_FONT_SIZE = 30;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
component: {
|
||||
alignItems: "center",
|
||||
},
|
||||
dimensions: {
|
||||
fontSize: BIG_FONT_SIZE,
|
||||
},
|
||||
price: {
|
||||
fontSize: BIG_FONT_SIZE,
|
||||
},
|
||||
date: {
|
||||
fontSize: BIG_FONT_SIZE,
|
||||
},
|
||||
tagColor: {
|
||||
fontSize: BIG_FONT_SIZE,
|
||||
},
|
||||
})
|
169
components/CarpetRollCalculator.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, StyleSheet, StatusBar, Dimensions, useWindowDimensions } from "react-native";
|
||||
import { Product } from "@/lib/product";
|
||||
import { selectProducts } from "@/features/product/productSlice";
|
||||
import { area_t, diameterToLength, length_t } from "@/lib/dimensions";
|
||||
import { useAppSelector } from "../app/store";
|
||||
import { AreaRugTag } from "@/components/AreaRugTag";
|
||||
import convert, { Length } from "convert";
|
||||
import ProductList from "@/components/ProductList";
|
||||
import { HelpfulMeasurementUnitInput } from "./HelpfulMeasurementInput";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
const DEFAULT_DIAMETER_UNIT: Length = "in";
|
||||
const DEFAULT_LENGTH_UNIT: Length = "ft";
|
||||
|
||||
const screenDimensions = Dimensions.get('screen');
|
||||
const windowDimensions = Dimensions.get('window');
|
||||
|
||||
export const CarpetRollCalculator = () => {
|
||||
const products = useAppSelector(selectProducts);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [outerDiameter, setOuterDiameter] = useState<length_t>({
|
||||
l: 0,
|
||||
u: DEFAULT_DIAMETER_UNIT,
|
||||
});
|
||||
const [innerDiameter, setInnerDiameter] = useState<length_t>({
|
||||
l: 0,
|
||||
u: DEFAULT_DIAMETER_UNIT,
|
||||
});
|
||||
const [numRings, setNumRings] = useState(0);
|
||||
const [rugDimensions, setRugDimensions] = useState<area_t>({
|
||||
u: DEFAULT_LENGTH_UNIT,
|
||||
w: 0,
|
||||
l: 0,
|
||||
});
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [units, setUnits] = useState<Length>(DEFAULT_LENGTH_UNIT);
|
||||
|
||||
useEffect(() => {
|
||||
// convert the "diameter" units to the length unit.
|
||||
|
||||
const outerD2Value = convert(outerDiameter.l, outerDiameter.u).to(units);
|
||||
const innerD2Value = convert(innerDiameter.l, innerDiameter.u).to(units);
|
||||
|
||||
const innerD2 = {
|
||||
l: innerD2Value,
|
||||
u: units,
|
||||
};
|
||||
const outerD2 = {
|
||||
l: outerD2Value,
|
||||
u: units,
|
||||
};
|
||||
|
||||
const l = diameterToLength(outerD2, innerD2, numRings).l;
|
||||
|
||||
const dimens = {
|
||||
l,
|
||||
w: width || selectedProduct?.dimensions.l || 0.0,
|
||||
u: units || selectedProduct?.dimensions.u || "ft",
|
||||
};
|
||||
console.dir(dimens);
|
||||
setRugDimensions(dimens);
|
||||
}, [outerDiameter, innerDiameter, width, numRings, selectedProduct, units]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.placeholder}>
|
||||
{selectedProduct ? (
|
||||
<AreaRugTag dimensions={rugDimensions} product={selectedProduct} />
|
||||
) : (
|
||||
<Text style={styles.placeholderText}>Please Select a Product</Text>
|
||||
)}
|
||||
</View>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<View>
|
||||
<View style={styles.inputFieldWrapper}>
|
||||
<HelpfulMeasurementUnitInput
|
||||
label="Length"
|
||||
svgUri="/assets/images/icons/carpet-roll-length-raw.svg"
|
||||
onUnitSet={setUnits}
|
||||
onValueSet={setWidth}
|
||||
defaultValue={width}
|
||||
defaultUnit={units}
|
||||
unitChoices={["ft", "in"]}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputFieldWrapper}>
|
||||
<HelpfulMeasurementUnitInput
|
||||
label="Inner diameter"
|
||||
svgUri="/assets/images/icons/carpet-roll-length-inner-diameter-raw.svg"
|
||||
onUnitSet={(u) => setInnerDiameter({ ...innerDiameter, u })}
|
||||
defaultValue={innerDiameter.l}
|
||||
defaultUnit={innerDiameter.u}
|
||||
unitChoices={["ft", "in"]}
|
||||
onValueSet={(l) => setInnerDiameter({ ...innerDiameter, l })}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputFieldWrapper}>
|
||||
<HelpfulMeasurementUnitInput
|
||||
label="Outer diameter"
|
||||
svgUri="/assets/images/icons/carpet-roll-length-outer-diameter-raw.svg"
|
||||
onUnitSet={(u) => setOuterDiameter({ ...outerDiameter, u })}
|
||||
defaultValue={innerDiameter.l}
|
||||
defaultUnit={innerDiameter.u}
|
||||
unitChoices={["ft", "in"]}
|
||||
onValueSet={(l) => setOuterDiameter({ ...outerDiameter, l })}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputFieldWrapper}>
|
||||
<HelpfulMeasurementUnitInput
|
||||
label="Number of rings"
|
||||
svgUri="/assets/images/icons/carpet-roll-length-number-of-rings-raw.svg"
|
||||
defaultValue={0}
|
||||
onValueSet={setNumRings}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<ProductList
|
||||
onProductSelected={setSelectedProduct}
|
||||
productType="area_rug"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: StatusBar.currentHeight,
|
||||
},
|
||||
scrollView: {
|
||||
marginHorizontal: 20,
|
||||
height: windowDimensions.height - 50,
|
||||
},
|
||||
placeholder: {
|
||||
alignContent: "center",
|
||||
alignSelf: "center",
|
||||
height: 300,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 40,
|
||||
fontSize: 30,
|
||||
position: "static",
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: 30,
|
||||
paddingVertical: 70,
|
||||
},
|
||||
inputFieldWrapper: {
|
||||
// padding: 10,
|
||||
},
|
||||
inputFields: {},
|
||||
label: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
},
|
||||
numberInput: {
|
||||
flexDirection: "row",
|
||||
borderStyle: "solid",
|
||||
borderColor: "black",
|
||||
borderWidth: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default CarpetRollCalculator;
|
33
components/HelpfulMeasurementInput.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { MeasurementInputProps } from "./MeasurementInput";
|
||||
import MeasurementUnitInput, {
|
||||
MeasurementUnitInputProps,
|
||||
} from "./MeasurementUnitInput";
|
||||
import { SvgUri } from "react-native-svg";
|
||||
import { Length } from "convert";
|
||||
|
||||
export type HelpfulMeasurementUnitInputParams = MeasurementUnitInputProps & {
|
||||
svgUri: string;
|
||||
label: string;
|
||||
unitChoices?: Length[];
|
||||
};
|
||||
|
||||
export function HelpfulMeasurementUnitInput(
|
||||
props: HelpfulMeasurementUnitInputParams
|
||||
) {
|
||||
return (
|
||||
<View>
|
||||
<SvgUri uri={props.svgUri} width="100px" height="100px" />
|
||||
<Text>{props.label}</Text>
|
||||
<MeasurementUnitInput
|
||||
defaultUnit={props.defaultUnit || "ft"}
|
||||
defaultValue={props.defaultValue || 0.0}
|
||||
onUnitSet={props.onUnitSet}
|
||||
onValueSet={props.onValueSet}
|
||||
unitChoices={props.unitChoices}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({});
|
@ -1,43 +1,24 @@
|
||||
import { dimensions_t, length_t } from "@/lib/product";
|
||||
import { Length } from "convert";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { NumberInput, NumberInputProps } from "./NumberInput";
|
||||
|
||||
export type t_length_unit = "foot" | "inch"
|
||||
|
||||
export type MeasurementInputProps = {
|
||||
onValueSet?: (d: dimensions_t) => any,
|
||||
defaultValue: length_t;
|
||||
label?: string,
|
||||
export type MeasurementInputProps = NumberInputProps & {
|
||||
units?: Length,
|
||||
}
|
||||
|
||||
export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementInputProps) {
|
||||
export function MeasurementInput({onValueSet, defaultValue: defaultValue, label, units}: MeasurementInputProps) {
|
||||
|
||||
const [mValue, setMValue] = useState(defaultValue)
|
||||
const defValue = Number.isNaN(defaultValue.l) ? 0 : defaultValue.l
|
||||
|
||||
function doOnValueSet(value : string) {
|
||||
setMValue(mValue);
|
||||
const iVal = parseFloat(value) || parseInt(value);
|
||||
onValueSet && onValueSet({
|
||||
...defaultValue,
|
||||
l: iVal,
|
||||
})
|
||||
}
|
||||
|
||||
const sDefValue = new String(defValue).valueOf()
|
||||
units = units || "ft";
|
||||
|
||||
return (
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
clearTextOnFocus={true}
|
||||
defaultValue={sDefValue}
|
||||
onChangeText={doOnValueSet}
|
||||
inputMode='decimal'
|
||||
style={styles.lengthInput}
|
||||
aria-label={label || "Enter measurement"}
|
||||
/>
|
||||
<Text style={styles.unitHints}>{mValue.u}</Text>
|
||||
<NumberInput
|
||||
onValueSet={v => onValueSet && onValueSet(v)}
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -45,10 +26,13 @@ export function MeasurementInput({onValueSet, defaultValue, label}: MeasurementI
|
||||
const styles = StyleSheet.create({
|
||||
inputWrapper: {
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "row"
|
||||
flexDirection: "row",
|
||||
verticalAlign: "middle"
|
||||
},
|
||||
unitHints: {
|
||||
padding: 10,
|
||||
fontSize: 20,
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
lengthInput: {
|
||||
borderWidth: 1,
|
||||
@ -57,6 +41,5 @@ const styles = StyleSheet.create({
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
fontSize: 25,
|
||||
width: 100,
|
||||
},
|
||||
})
|
51
components/MeasurementUnitInput.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { dimensions_t, length_t } from "@/lib/dimensions";
|
||||
import { Length } from "convert";
|
||||
import { MeasurementInput, MeasurementInputProps } from "./MeasurementInput";
|
||||
import UnitChooser, {
|
||||
UnitChooserPropsBase,
|
||||
} from "./UnitChooser";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
|
||||
export type MeasurementUnitInputProps = MeasurementInputProps &
|
||||
UnitChooserPropsBase & {
|
||||
defaultValue: number;
|
||||
unitChoices?: Length[];
|
||||
};
|
||||
|
||||
export default function MeasurementUnitInput({
|
||||
onValueSet,
|
||||
onUnitSet,
|
||||
defaultValue,
|
||||
unitChoices,
|
||||
defaultUnit,
|
||||
label,
|
||||
units,
|
||||
}: MeasurementUnitInputProps) {
|
||||
return (
|
||||
<View style={unitChoices ? styles.inputRow : styles.inputCol}>
|
||||
<MeasurementInput
|
||||
onValueSet={onValueSet}
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
units={units}
|
||||
/>
|
||||
{unitChoices && (
|
||||
<UnitChooser
|
||||
defaultUnit={defaultUnit}
|
||||
choices={unitChoices}
|
||||
onUnitSet={onUnitSet}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputRow: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
inputCol: {
|
||||
|
||||
}
|
||||
})
|
54
components/NumberInput.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { StyleSheet, TextInput } from "react-native";
|
||||
|
||||
export type NumberInputProps = {
|
||||
defaultValue: number;
|
||||
onValueSet: (value: number) => any;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function NumberInput({
|
||||
defaultValue,
|
||||
onValueSet,
|
||||
label,
|
||||
}: NumberInputProps) {
|
||||
const defValue = Number.isNaN(defaultValue) ? 0 : defaultValue;
|
||||
|
||||
function doOnValueSet(value: string) {
|
||||
const iVal = parseFloat(value) || parseInt(value);
|
||||
onValueSet && onValueSet(iVal);
|
||||
}
|
||||
|
||||
const sDefValue = new String(defValue).valueOf();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
clearTextOnFocus={true}
|
||||
defaultValue={sDefValue}
|
||||
onChangeText={doOnValueSet}
|
||||
inputMode="decimal"
|
||||
style={styles.numberInput}
|
||||
aria-label={label || "Enter measurement"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputWrapper: {
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "row",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
unitHints: {
|
||||
padding: 10,
|
||||
fontSize: 20,
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
numberInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
borderColor: "grey",
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
fontSize: 25,
|
||||
},
|
||||
});
|
@ -1,27 +1,36 @@
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import Slider from '@react-native-community/slider';
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type PercentDamageProps = {
|
||||
onSetPercentage: (percent: number) => any;
|
||||
}
|
||||
|
||||
export default function PercentDamage ({onSetPercentage} : PercentDamageProps) {
|
||||
function getDamangeColor(damage : number) {
|
||||
if (damage === 0) return "black";
|
||||
if (damage <= 20) return "blue";
|
||||
if (damage <= 50) return "orange";
|
||||
return "red";
|
||||
}
|
||||
|
||||
export default function PercentDamage({ onSetPercentage }: PercentDamageProps) {
|
||||
const [damage, setDamage] = useState(0);
|
||||
function doOnChangeText (val : number) {
|
||||
setDamage(val);
|
||||
onSetPercentage(val / 100);
|
||||
const [damageColor, setDamageColor] = useState("black");
|
||||
function doOnChangeText(val: number) {
|
||||
setDamage(val || 0);
|
||||
onSetPercentage((val / 100) || 0);
|
||||
setDamageColor(getDamangeColor(val || 0));
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Slider
|
||||
value={damage}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
step={5}
|
||||
onValueChange={doOnChangeText}
|
||||
/>
|
||||
<Text style={styles.label}> {damage}% Damage</Text>
|
||||
/>
|
||||
<Text style={{ ...styles.label, color: damageColor }}> {damage}% Damage</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -40,5 +49,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
label: {
|
||||
margin: 5,
|
||||
alignSelf: "center",
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
fontStyle: "italic",
|
||||
}
|
||||
})
|
@ -1,68 +1,87 @@
|
||||
import { Product } from "@/lib/product";
|
||||
import { product_type_t } from "@/lib/dimensions";
|
||||
import { PRODUCT_TYPES, Product } from "@/lib/product";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { StyleSheet, Text, TextInput, TouchableHighlight, View } from "react-native";
|
||||
import { StyleSheet, TextInput, TouchableHighlight, View } from "react-native";
|
||||
import SelectDropdown from "react-native-select-dropdown";
|
||||
|
||||
export type ProductAttributeChangeFunc = (key: string, newValue: string) => any;
|
||||
export type ProductAttributeDeleteFunc = (key: string) => any;
|
||||
export type ChangeAttributeFunction = (oldKey : string, newKey : string) => any;
|
||||
export type ChangeAttributeFunction = (oldKey: string, newKey: string) => any;
|
||||
export type ProductTypeChangeFunc = (
|
||||
key: string,
|
||||
newProductType: product_type_t
|
||||
) => any;
|
||||
|
||||
export type ProductAttributeProps = {
|
||||
attributeKey: string,
|
||||
attributeValue: string,
|
||||
onChangeAttributeKey?: ChangeAttributeFunction,
|
||||
onChangeAttribute?: ProductAttributeChangeFunc,
|
||||
onDelete?: ProductAttributeChangeFunc,
|
||||
attributeKey: string;
|
||||
attributeValue: string;
|
||||
onProductTypeChange?: ProductTypeChangeFunc;
|
||||
onChangeAttributeKey?: ChangeAttributeFunction;
|
||||
onChangeAttribute?: ProductAttributeChangeFunc;
|
||||
onDelete?: ProductAttributeChangeFunc;
|
||||
};
|
||||
|
||||
export const ProductAttributeEditor = ({ attributeKey, attributeValue, onDelete, onChangeAttributeKey, onChangeAttribute }: ProductAttributeProps) => {
|
||||
const select_product_type_choices = PRODUCT_TYPES.map((p) => [p, p]);
|
||||
|
||||
const doChangeKey = (e: any) => {
|
||||
onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
|
||||
}
|
||||
export const ProductAttributeEditor = ({
|
||||
attributeKey,
|
||||
attributeValue,
|
||||
onDelete,
|
||||
onChangeAttributeKey,
|
||||
onChangeAttribute,
|
||||
}: ProductAttributeProps) => {
|
||||
const doChangeKey = (e: any) => {
|
||||
onChangeAttributeKey && onChangeAttributeKey(attributeKey, e);
|
||||
};
|
||||
|
||||
const doChangeValue = (e: any) => {
|
||||
onChangeAttribute && onChangeAttribute(attributeKey, e);
|
||||
}
|
||||
const doChangeValue = (e: any) => {
|
||||
onChangeAttribute && onChangeAttribute(attributeKey, e);
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.productAttributeRow}>
|
||||
<TextInput
|
||||
defaultValue={attributeKey}
|
||||
onChangeText={doChangeKey}
|
||||
style={styles.value}
|
||||
aria-label="Edit Key"
|
||||
/>
|
||||
<TextInput
|
||||
defaultValue={attributeValue}
|
||||
onChangeText={doChangeValue}
|
||||
style={styles.value}
|
||||
aria-label="Edit Value" />
|
||||
<TouchableHighlight
|
||||
onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
|
||||
aria-label="Delete Attribute"
|
||||
style={{ backgroundColor: "darkred", borderRadius: 5, margin: 5, padding: 5, }}>
|
||||
<Ionicons name="trash-bin-outline" size={30} color={"white"} />
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.productAttributeRow}>
|
||||
<TextInput
|
||||
defaultValue={attributeKey}
|
||||
onChangeText={doChangeKey}
|
||||
style={styles.value}
|
||||
aria-label="Edit Key"
|
||||
/>
|
||||
<TextInput
|
||||
defaultValue={attributeValue}
|
||||
onChangeText={doChangeValue}
|
||||
style={styles.value}
|
||||
aria-label="Edit Value"
|
||||
/>
|
||||
<TouchableHighlight
|
||||
onPress={() => onDelete && onDelete(attributeKey, attributeValue)}
|
||||
aria-label="Delete Attribute"
|
||||
style={{
|
||||
backgroundColor: "darkred",
|
||||
borderRadius: 5,
|
||||
margin: 5,
|
||||
padding: 5,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-bin-outline" size={30} color={"white"} />
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
productAttributeRow: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
key: {
|
||||
flex: 1,
|
||||
},
|
||||
value: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: "grey",
|
||||
borderStyle: "solid",
|
||||
padding: 10
|
||||
}
|
||||
});
|
||||
productAttributeRow: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
key: {
|
||||
flex: 1,
|
||||
},
|
||||
value: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: "grey",
|
||||
borderStyle: "solid",
|
||||
padding: 10,
|
||||
},
|
||||
});
|
||||
|
@ -1,198 +1,204 @@
|
||||
import { Product, dimensions_t } from '@/lib/product';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import PriceDisplay from './Price';
|
||||
import { AreaInput } from './AreaInput';
|
||||
import { MeasurementInput } from './MeasurementInput';
|
||||
import ProductList from './ProductList';
|
||||
import UnitChooser from './UnitChooser';
|
||||
import convert, { Length } from 'convert';
|
||||
import PercentDamage from './PercentDamange';
|
||||
|
||||
import { Product, productPriceFor } from "@/lib/product";
|
||||
import { dimensions_t } from "@/lib/dimensions";
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import PriceDisplay from "./Price";
|
||||
import { AreaInput } from "./AreaInput";
|
||||
import ProductList from "./ProductList";
|
||||
import convert, { Length } from "convert";
|
||||
import PercentDamage from "./PercentDamange";
|
||||
import MeasurementUnitInput from "./MeasurementUnitInput";
|
||||
import UnitChooser from "./UnitChooser";
|
||||
|
||||
export default function ProductCalculatorSelector() {
|
||||
const [activeProduct, setActiveProduct] = useState(null as Product | null);
|
||||
const [price, setPrice] = useState(0);
|
||||
const [measurement, setMeasurement] = useState({
|
||||
l: 0,
|
||||
w: 0,
|
||||
u: "ft",
|
||||
} as dimensions_t);
|
||||
const [percentDamage, setPercentDamange] = useState(0.0);
|
||||
|
||||
const [activeProduct, setActiveProduct] = useState(null as Product | null);
|
||||
const [price, setPrice] = useState(0);
|
||||
const [measurement, setMeasurement] = useState({ l: 0, w: 0, u: "ft" } as dimensions_t);
|
||||
const [percentDamage, setPercentDamange] = useState(0.0);
|
||||
useEffect(
|
||||
function () {
|
||||
const iv = setInterval(function () {
|
||||
if (!(activeProduct && measurement)) return;
|
||||
setPrice(productPriceFor(activeProduct, measurement, percentDamage));
|
||||
}, 50);
|
||||
return function () {
|
||||
clearInterval(iv);
|
||||
};
|
||||
},
|
||||
[activeProduct, measurement, percentDamage]
|
||||
);
|
||||
|
||||
useEffect(function () {
|
||||
const iv = setInterval(function () {
|
||||
if (!(activeProduct && measurement)) return;
|
||||
setPrice(
|
||||
activeProduct.priceFor(measurement, percentDamage)
|
||||
function onMeasurementSet(dimensions: dimensions_t) {
|
||||
setMeasurement(dimensions);
|
||||
activeProduct &&
|
||||
setPrice(productPriceFor(activeProduct, measurement, percentDamage));
|
||||
}
|
||||
|
||||
function onLengthSet(l: number) {
|
||||
setMeasurement({ ...measurement, l });
|
||||
onMeasurementSet && onMeasurementSet({ ...measurement, l });
|
||||
}
|
||||
|
||||
function onUnitChosen(unit: Length) {
|
||||
setMeasurement({
|
||||
...measurement,
|
||||
u: unit,
|
||||
});
|
||||
}
|
||||
|
||||
function onSetPercentDamage(pct: number) {
|
||||
setPercentDamange(pct);
|
||||
}
|
||||
|
||||
function onProductSelected(product: Product) {
|
||||
setActiveProduct(product);
|
||||
setMeasurement({
|
||||
l: convert(product.dimensions.l, product.dimensions.u).to(measurement.u),
|
||||
u: measurement.u,
|
||||
...("w" in measurement && "w" in product.dimensions
|
||||
? {
|
||||
w: convert(product.dimensions.w, product.dimensions.u).to(
|
||||
measurement.u
|
||||
),
|
||||
u: measurement.u,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.wrapper}>
|
||||
<PriceDisplay price={price} />
|
||||
<View style={styles.inputAndUnitWrapper}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{activeProduct ? (
|
||||
"w" in activeProduct.dimensions ? (
|
||||
<View style={{flex: 1, flexDirection: "row"}}>
|
||||
<AreaInput
|
||||
defaultValue={activeProduct.dimensions}
|
||||
onMeasurementSet={onMeasurementSet}
|
||||
widthLabel="enter width"
|
||||
lengthLabel="enter length"
|
||||
units={measurement.u}
|
||||
/>
|
||||
<UnitChooser
|
||||
choices={["in", "ft"]}
|
||||
onUnitSet={onUnitChosen}
|
||||
defaultUnit={activeProduct.dimensions.u}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<MeasurementUnitInput
|
||||
defaultValue={activeProduct.dimensions.l}
|
||||
onValueSet={onLengthSet}
|
||||
onUnitSet={onUnitChosen}
|
||||
defaultUnit={activeProduct.dimensions.u}
|
||||
unitChoices={["ft", "in"]}
|
||||
/>
|
||||
)
|
||||
}, 50);
|
||||
return function () {
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, [activeProduct, measurement, percentDamage]);
|
||||
|
||||
function onMeasurementSet(dimensions: dimensions_t) {
|
||||
setMeasurement(dimensions);
|
||||
activeProduct && setPrice(
|
||||
activeProduct.priceFor(measurement, percentDamage)
|
||||
)
|
||||
}
|
||||
|
||||
function onUnitChosen(unit: Length) {
|
||||
setMeasurement({
|
||||
...measurement,
|
||||
u: unit,
|
||||
});
|
||||
}
|
||||
|
||||
function onSetPercentDamage(pct: number) {
|
||||
setPercentDamange(pct);
|
||||
}
|
||||
|
||||
function onProductSelected(product: Product) {
|
||||
setActiveProduct(product);
|
||||
setMeasurement(
|
||||
{
|
||||
l: convert(
|
||||
product.dimensions.l,
|
||||
product.dimensions.u,
|
||||
).to(measurement.u),
|
||||
u: measurement.u,
|
||||
...(
|
||||
("w" in measurement && "w" in product.dimensions) ? {
|
||||
w: convert(
|
||||
product.dimensions.w,
|
||||
product.dimensions.u,
|
||||
).to(measurement.u),
|
||||
u: measurement.u,
|
||||
} : {}
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.wrapper}>
|
||||
<PriceDisplay price={price} />
|
||||
<View style={styles.inputAndUnitWrapper}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{
|
||||
activeProduct ? (
|
||||
"w" in activeProduct.dimensions ?
|
||||
<AreaInput
|
||||
defaultValue={activeProduct.dimensions}
|
||||
onMeasurementSet={onMeasurementSet}
|
||||
widthLabel='enter width'
|
||||
lengthLabel='enter length'
|
||||
/>
|
||||
:
|
||||
<MeasurementInput
|
||||
defaultValue={activeProduct.dimensions}
|
||||
onValueSet={onMeasurementSet}
|
||||
label="enter length"
|
||||
/>
|
||||
|
||||
) : (
|
||||
<Text>Please select a product</Text>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeProduct && <UnitChooser choices={["in", "ft"]} onChoicePressed={onUnitChosen} />
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
{activeProduct &&
|
||||
(<View >
|
||||
<PercentDamage
|
||||
onSetPercentage={onSetPercentDamage}
|
||||
/>
|
||||
</View>)
|
||||
}
|
||||
<ProductList onProductSelected={onProductSelected} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
) : (
|
||||
<Text>Please select a product</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{activeProduct && (
|
||||
<View style={styles.damageWrapper}>
|
||||
<PercentDamage onSetPercentage={onSetPercentDamage} />
|
||||
</View>
|
||||
)}
|
||||
<ProductList onProductSelected={onProductSelected} productType="lumber" />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
overflow: "scroll"
|
||||
},
|
||||
bigPriceWrapper: {
|
||||
alignContent: "center",
|
||||
},
|
||||
bigPrice: {
|
||||
alignSelf: "center",
|
||||
fontSize: 40,
|
||||
marginTop: 100,
|
||||
marginBottom: 100,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
unitSelector: {
|
||||
},
|
||||
inputAndUnitWrapper: {
|
||||
flexDirection: "row",
|
||||
alignSelf: "center",
|
||||
},
|
||||
widthInput: {
|
||||
width: 200,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
borderColor: "grey",
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
fontSize: 30,
|
||||
},
|
||||
activeProduct: {
|
||||
borderWidth: 2,
|
||||
borderColor: "black",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
inactiveProduct: {
|
||||
wrapper: {
|
||||
overflow: "scroll",
|
||||
},
|
||||
bigPriceWrapper: {
|
||||
alignContent: "center",
|
||||
},
|
||||
bigPrice: {
|
||||
alignSelf: "center",
|
||||
fontSize: 40,
|
||||
marginTop: 100,
|
||||
marginBottom: 100,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
verticalAlign: "middle",
|
||||
},
|
||||
unitSelector: {},
|
||||
inputAndUnitWrapper: {
|
||||
flexDirection: "row",
|
||||
alignSelf: "center",
|
||||
},
|
||||
widthInput: {
|
||||
width: 200,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
borderColor: "grey",
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
fontSize: 30,
|
||||
},
|
||||
activeProduct: {
|
||||
borderWidth: 2,
|
||||
borderColor: "black",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
inactiveProduct: {},
|
||||
titleContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
productTileTouchable: {
|
||||
margin: 10,
|
||||
padding: 20,
|
||||
backgroundColor: "grey",
|
||||
},
|
||||
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
stepContainer: {
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
reactLogo: {
|
||||
height: 178,
|
||||
width: 290,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
productTileTouchable: {
|
||||
margin: 10,
|
||||
padding: 20,
|
||||
backgroundColor: "grey",
|
||||
},
|
||||
productTileTouchableActive: {
|
||||
borderWidth: 2,
|
||||
borderStyle: "solid",
|
||||
borderColor: "black",
|
||||
margin: 10,
|
||||
padding: 20,
|
||||
},
|
||||
|
||||
productTileTouchableActive: {
|
||||
borderWidth: 2,
|
||||
borderStyle: "solid",
|
||||
borderColor: "black",
|
||||
margin: 10,
|
||||
padding: 20,
|
||||
},
|
||||
productTileText: {
|
||||
textAlign: "center",
|
||||
color: "white",
|
||||
},
|
||||
|
||||
productTileText: {
|
||||
textAlign: "center",
|
||||
color: "white",
|
||||
},
|
||||
productTileTextActive: {
|
||||
textAlign: "center",
|
||||
color: "black",
|
||||
},
|
||||
|
||||
productTileTextActive: {
|
||||
textAlign: "center",
|
||||
color: "black",
|
||||
},
|
||||
|
||||
productTileCover: {
|
||||
padding: 4,
|
||||
},
|
||||
productTileCover: {
|
||||
padding: 4,
|
||||
},
|
||||
|
||||
damageWrapper: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
});
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store"
|
||||
import { addAttribute, changeKey, deleteAttribute, deleteProduct, selectProductIds, selectProducts, updateAttribute, updateDimensions, updatePrice, updateProduct } from "@/features/product/productSlice"
|
||||
import { Id, Product, dimensions_t } from "@/lib/product";
|
||||
import { Id, Product } from "@/lib/product";
|
||||
import { FlatList, SafeAreaView, StyleSheet, Text } from "react-native";
|
||||
import { ProductEditorItem } from "./ProductEditorItem";
|
||||
import { dimensions_t } from "@/lib/dimensions";
|
||||
|
||||
export const ProductEditor = ({}) => {
|
||||
const products = useAppSelector(selectProducts) as Product [];
|
||||
|
@ -1,230 +1,275 @@
|
||||
import { Id, Product, dimensions_t } from "@/lib/product"
|
||||
import { useState } from "react"
|
||||
import { Button, FlatList, StyleSheet, Text, TextInput, Touchable, TouchableHighlight, View } from "react-native"
|
||||
import { Id, Product, product_type_t } from "@/lib/product";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
FlatList,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
Touchable,
|
||||
TouchableHighlight,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { ProductAttributeEditor } from "./ProductAttributeEditor";
|
||||
import { Dropdown } from 'react-native-element-dropdown';
|
||||
import { Dropdown } from "react-native-element-dropdown";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Length } from "convert";
|
||||
import { dimensions_t } from "@/lib/dimensions";
|
||||
|
||||
export type ProductAddedFunc = () => any;
|
||||
export type ProductDeletedFunc = (product_id: Id) => any;
|
||||
export type AttributeAddedFunc = (product_id: Id) => any;
|
||||
export type AttributeKeyUpdatedFunc = (product_id: Id, oldKey: string, newKey: string) => any;
|
||||
export type AttributeUpdatedFunc = (product_id: Id, attribute: string, value: string) => any;
|
||||
export type AttributeKeyUpdatedFunc = (
|
||||
product_id: Id,
|
||||
oldKey: string,
|
||||
newKey: string
|
||||
) => any;
|
||||
export type AttributeUpdatedFunc = (
|
||||
product_id: Id,
|
||||
attribute: string,
|
||||
value: string
|
||||
) => any;
|
||||
export type AttributeDeletedFunc = (product_id: Id, attribute: string) => any;
|
||||
export type PriceUpdatedFunc = (product_id: Id, price: number) => any;
|
||||
export type DimensionUpdatedFunc = (product_id: Id, dimension: dimensions_t) => any;
|
||||
export type DimensionUpdatedFunc = (
|
||||
product_id: Id,
|
||||
dimension: dimensions_t
|
||||
) => any;
|
||||
export type ProductTypeChangedFunc = (
|
||||
product_id: Id,
|
||||
product_type: product_type_t
|
||||
) => any;
|
||||
|
||||
export type ProductEditorItemProps = {
|
||||
product: Product,
|
||||
onProductAdded?: ProductAddedFunc,
|
||||
onProductDeleted?: ProductDeletedFunc,
|
||||
onAttributeAdded?: AttributeAddedFunc,
|
||||
onAttributeKeyChanged?: AttributeKeyUpdatedFunc,
|
||||
onAttributeUpdated?: AttributeUpdatedFunc,
|
||||
onAttributeDeleted?: AttributeDeletedFunc,
|
||||
onPriceUpdated?: PriceUpdatedFunc,
|
||||
onDimensionsUpdated?: DimensionUpdatedFunc,
|
||||
}
|
||||
product: Product;
|
||||
onProductAdded?: ProductAddedFunc;
|
||||
onProductDeleted?: ProductDeletedFunc;
|
||||
onAttributeAdded?: AttributeAddedFunc;
|
||||
onAttributeKeyChanged?: AttributeKeyUpdatedFunc;
|
||||
onAttributeUpdated?: AttributeUpdatedFunc;
|
||||
onAttributeDeleted?: AttributeDeletedFunc;
|
||||
onPriceUpdated?: PriceUpdatedFunc;
|
||||
onDimensionsUpdated?: DimensionUpdatedFunc;
|
||||
onProductTypeChanged?: ProductTypeChangedFunc;
|
||||
};
|
||||
|
||||
export const ProductEditorItem = (props: ProductEditorItemProps) => {
|
||||
const [showAttributes, setShowAttributes] = useState(false);
|
||||
const product = props.product;
|
||||
|
||||
const [showAttributes, setShowAttributes] = useState(false);
|
||||
const product = props.product;
|
||||
function onProductTypeChange(id: Id, newProductType: product_type_t) {
|
||||
props.onProductTypeChanged &&
|
||||
props.onProductTypeChanged(product.id as Id, newProductType);
|
||||
}
|
||||
|
||||
function onAttributeChanged(key: string, newValue: string) {
|
||||
props.onAttributeUpdated && props.onAttributeUpdated(product.id, key, newValue);
|
||||
}
|
||||
function onAttributeChanged(key: string, newValue: string) {
|
||||
props.onAttributeUpdated &&
|
||||
props.onAttributeUpdated(product.id as Id, key, newValue);
|
||||
}
|
||||
|
||||
function onAttributeKeyChanged(oldKey: string, newKey: string) {
|
||||
props.onAttributeKeyChanged && props.onAttributeKeyChanged(product.id, oldKey, newKey);
|
||||
}
|
||||
function onAttributeKeyChanged(oldKey: string, newKey: string) {
|
||||
props.onAttributeKeyChanged &&
|
||||
props.onAttributeKeyChanged(product.id as Id, oldKey, newKey);
|
||||
}
|
||||
|
||||
function onAttributeDelete(key: string) {
|
||||
props.onAttributeDeleted && props.onAttributeDeleted(product.id, key);
|
||||
}
|
||||
function onAttributeDelete(key: string) {
|
||||
props.onAttributeDeleted && props.onAttributeDeleted(product.id as Id, key);
|
||||
}
|
||||
|
||||
function onPricePerUnitChange(pricePerUnit: string) {
|
||||
props.onPriceUpdated && props.onPriceUpdated(product.id, parseFloat(pricePerUnit) || parseInt(pricePerUnit));
|
||||
}
|
||||
function onPricePerUnitChange(pricePerUnit: string) {
|
||||
props.onPriceUpdated &&
|
||||
props.onPriceUpdated(
|
||||
product.id as Id,
|
||||
parseFloat(pricePerUnit) || parseInt(pricePerUnit)
|
||||
);
|
||||
}
|
||||
|
||||
function onUnitsChanged(newUnits: Length) {
|
||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
||||
...(product.dimensions as dimensions_t),
|
||||
u: newUnits,
|
||||
})
|
||||
}
|
||||
function onUnitsChanged(newUnits: Length) {
|
||||
props.onDimensionsUpdated &&
|
||||
props.onDimensionsUpdated(product.id as Id, {
|
||||
...(product.dimensions as dimensions_t),
|
||||
u: newUnits,
|
||||
});
|
||||
}
|
||||
|
||||
function onChangeLength(len: string) {
|
||||
const l = parseFloat(len) || parseInt(len);
|
||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
||||
...(product.dimensions as dimensions_t),
|
||||
l,
|
||||
})
|
||||
}
|
||||
function onChangeLength(len: string) {
|
||||
const l = parseFloat(len) || parseInt(len);
|
||||
props.onDimensionsUpdated &&
|
||||
props.onDimensionsUpdated(product.id as Id, {
|
||||
...(product.dimensions as dimensions_t),
|
||||
l,
|
||||
});
|
||||
}
|
||||
|
||||
function onChangeWidth(width: string) {
|
||||
const w = width.length == 0 ? null : parseFloat(width) || parseInt(width);
|
||||
props.onDimensionsUpdated && props.onDimensionsUpdated(product.id, {
|
||||
...(product.dimensions as dimensions_t),
|
||||
...(w ? {w} : {}),
|
||||
})
|
||||
}
|
||||
function onChangeWidth(width: string) {
|
||||
const w = width.length == 0 ? null : parseFloat(width) || parseInt(width);
|
||||
props.onDimensionsUpdated &&
|
||||
props.onDimensionsUpdated(product.id as Id, {
|
||||
...(product.dimensions as dimensions_t),
|
||||
...(w ? { w } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function onDeleteProduct() {
|
||||
props.onProductDeleted && props.onProductDeleted(product.id);
|
||||
}
|
||||
function onDeleteProduct() {
|
||||
props.onProductDeleted && props.onProductDeleted(product.id as Id);
|
||||
}
|
||||
|
||||
const length = new String(product.dimensions.l || product.dimensions.l || "0") as string;
|
||||
const width = new String(product.dimensions.w || "") as string;
|
||||
const dimension = product.dimensions.u || product.dimensions.u || "foot";
|
||||
const length = new String(
|
||||
product.dimensions.l || product.dimensions.l || "0"
|
||||
) as string;
|
||||
const width = new String(product.dimensions.w || "") as string;
|
||||
const dimension = product.dimensions.u || product.dimensions.u || "foot";
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.productListHeader}>
|
||||
<TouchableHighlight
|
||||
onPress={() => setShowAttributes(!showAttributes)}
|
||||
aria-label="Product Item"
|
||||
style={styles.productItemName}
|
||||
>
|
||||
<Text style={styles.productNameText}>{product.attributes.name || `Product ${product.id}`}</Text>
|
||||
</TouchableHighlight>
|
||||
<TouchableHighlight
|
||||
onPress={() => onDeleteProduct()}
|
||||
aria-label="delete product"
|
||||
style={styles.deleteProductHighlight}
|
||||
>
|
||||
<Ionicons
|
||||
style={styles.deleteProductButton}
|
||||
name="trash-outline"
|
||||
/>
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
{showAttributes &&
|
||||
(
|
||||
<View style={styles.detailsWrapper}>
|
||||
<View style={styles.priceSpecWrapper}>
|
||||
<Text style={styles.priceLabel}>$</Text>
|
||||
<TextInput inputMode="decimal"
|
||||
defaultValue={new String(product.pricePerUnit) as string}
|
||||
aria-label="price per unit"
|
||||
onChangeText={onPricePerUnitChange}
|
||||
style={styles.priceInput}
|
||||
/>
|
||||
<Text style={styles.per}>per</Text>
|
||||
<Dropdown
|
||||
data={[
|
||||
{label: "feet", value: "ft"},
|
||||
{label: "inches", value: "in"},
|
||||
]}
|
||||
style={styles.unitsSelect}
|
||||
mode="modal"
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
value={product.dimensions.u || "ft"}
|
||||
onChange={(item) => onUnitsChanged(item.value as Length)}
|
||||
/>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={length}
|
||||
onChangeText={onChangeLength}
|
||||
style={styles.lengthInput}
|
||||
aria-label="length"
|
||||
/>
|
||||
<Text style={{flex: 1,}}>x</Text>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={width}
|
||||
onChangeText={onChangeWidth}
|
||||
style={styles.widthInput}
|
||||
aria-label="width"
|
||||
/>
|
||||
</View>
|
||||
<Button title="+ Add Attribute" onPress={() => props.onAttributeAdded && props.onAttributeAdded(product.id)} />
|
||||
<FlatList
|
||||
style={styles.productAttributesList}
|
||||
data={Object.entries(product.attributes)}
|
||||
renderItem={({ item }) => (
|
||||
<ProductAttributeEditor
|
||||
attributeKey={item[0] || "some key"}
|
||||
attributeValue={item[1]}
|
||||
onChangeAttributeKey={onAttributeKeyChanged}
|
||||
onChangeAttribute={onAttributeChanged}
|
||||
onDelete={onAttributeDelete}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item, i) => `${product.id}-${i}`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.productListHeader}>
|
||||
<TouchableHighlight
|
||||
onPress={() => setShowAttributes(!showAttributes)}
|
||||
aria-label="Product Item"
|
||||
style={styles.productItemName}
|
||||
>
|
||||
{product.attributes && (
|
||||
<Text style={styles.productNameText}>
|
||||
{product.attributes.name || `Product ${product.id}`}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableHighlight>
|
||||
<Pressable
|
||||
onPress={() => onDeleteProduct()}
|
||||
aria-label="delete product"
|
||||
style={styles.deleteProductHighlight}
|
||||
>
|
||||
<Ionicons style={styles.deleteProductButton} name="trash-outline" />
|
||||
</Pressable>
|
||||
</View>
|
||||
{showAttributes && (
|
||||
<View style={styles.detailsWrapper}>
|
||||
<View style={styles.priceSpecWrapper}>
|
||||
<Text style={styles.priceLabel}>$</Text>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={new String(product.pricePerUnit).valueOf()}
|
||||
aria-label="price per unit"
|
||||
onChangeText={onPricePerUnitChange}
|
||||
style={styles.priceInput}
|
||||
/>
|
||||
<Text style={styles.per}>per</Text>
|
||||
<Dropdown
|
||||
data={[
|
||||
{ label: "feet", value: "ft" },
|
||||
{ label: "inches", value: "in" },
|
||||
]}
|
||||
style={styles.unitsSelect}
|
||||
mode="modal"
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
value={product.dimensions.u || "ft"}
|
||||
onChange={(item) => onUnitsChanged(item.value as Length)}
|
||||
/>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={length}
|
||||
onChangeText={onChangeLength}
|
||||
style={styles.lengthInput}
|
||||
aria-label="length"
|
||||
/>
|
||||
<Text style={{ flex: 1 }}>x</Text>
|
||||
<TextInput
|
||||
inputMode="decimal"
|
||||
defaultValue={width}
|
||||
onChangeText={onChangeWidth}
|
||||
style={styles.widthInput}
|
||||
aria-label="width"
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
title="+ Add Attribute"
|
||||
onPress={() =>
|
||||
props.onAttributeAdded && props.onAttributeAdded(product.id as Id)
|
||||
}
|
||||
/>
|
||||
{product.attributes && (
|
||||
<FlatList
|
||||
style={styles.productAttributesList}
|
||||
data={Object.entries(product.attributes)}
|
||||
renderItem={({ item }) => (
|
||||
<ProductAttributeEditor
|
||||
onProductTypeChange={onProductTypeChange}
|
||||
attributeKey={item[0] || "some key"}
|
||||
attributeValue={item[1]}
|
||||
onChangeAttributeKey={onAttributeKeyChanged}
|
||||
onChangeAttribute={onAttributeChanged}
|
||||
onDelete={onAttributeDelete}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item, i) => `${product.id}-${i}`}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deleteProductHighlight: {
|
||||
|
||||
padding: 5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
deleteProductButton: {
|
||||
fontSize: 20,
|
||||
},
|
||||
detailsWrapper: {
|
||||
|
||||
},
|
||||
priceSpecWrapper: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
priceLabel: {
|
||||
},
|
||||
priceInput: {
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
per: {
|
||||
padding: 5,
|
||||
},
|
||||
unitsLabel: {
|
||||
},
|
||||
unitsSelect: {
|
||||
flex: 1,
|
||||
padding: 5,
|
||||
},
|
||||
lengthInput: {
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
widthInput: {
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
productListHeader: {
|
||||
flexDirection: "row",
|
||||
padding: 5,
|
||||
},
|
||||
productNameText: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
productItemName: {
|
||||
flex: 1,
|
||||
backgroundColor: "lightgrey",
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
},
|
||||
productAttributesList: {
|
||||
margin: 10,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: "black",
|
||||
},
|
||||
})
|
||||
deleteProductHighlight: {
|
||||
padding: 5,
|
||||
borderWidth: 1,
|
||||
},
|
||||
deleteProductButton: {
|
||||
fontSize: 20,
|
||||
},
|
||||
detailsWrapper: {},
|
||||
priceSpecWrapper: {
|
||||
flexDirection: "row",
|
||||
},
|
||||
priceLabel: {},
|
||||
priceInput: {
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
per: {
|
||||
padding: 5,
|
||||
},
|
||||
unitsLabel: {},
|
||||
unitsSelect: {
|
||||
flex: 1,
|
||||
padding: 5,
|
||||
},
|
||||
lengthInput: {
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
widthInput: {
|
||||
flex: 1,
|
||||
borderWidth: 2,
|
||||
borderColor: "lightgrey",
|
||||
borderStyle: "solid",
|
||||
},
|
||||
productListHeader: {
|
||||
flexDirection: "row",
|
||||
padding: 5,
|
||||
},
|
||||
productNameText: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
productItemName: {
|
||||
flex: 1,
|
||||
backgroundColor: "lightgrey",
|
||||
padding: 4,
|
||||
margin: 4,
|
||||
},
|
||||
productAttributesList: {
|
||||
margin: 10,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: "black",
|
||||
},
|
||||
});
|
||||
|
@ -1,19 +1,28 @@
|
||||
import { FlatList, ScrollView, StyleSheet, Text, TouchableHighlight } from "react-native";
|
||||
import { Dimensions, ScrollView, StyleSheet } from "react-native";
|
||||
import { ProductTile } from "./ProductTile";
|
||||
import { Id, Product } from "@/lib/product";
|
||||
import { Key, useEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Product, product_type_t } from "@/lib/product";
|
||||
import { useState } from "react";
|
||||
import { selectProducts } from "@/features/product/productSlice";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
|
||||
const windowDimensions = Dimensions.get('window');
|
||||
|
||||
export type ProductSelectionProps = {
|
||||
onProductSelected?: (product: Product) => any;
|
||||
}
|
||||
|
||||
export default function ProductList({ onProductSelected }: ProductSelectionProps) {
|
||||
productType?: product_type_t;
|
||||
};
|
||||
|
||||
export default function ProductList({
|
||||
productType,
|
||||
onProductSelected,
|
||||
}: ProductSelectionProps) {
|
||||
const [activeProduct, setActiveProduct] = useState(null as null | Product);
|
||||
const products = useAppSelector(selectProducts).filter(p => (!!p.dimensions));
|
||||
const products = useAppSelector(selectProducts)
|
||||
.filter((p) => !!p)
|
||||
.filter((p: Product) => (!productType) || p.type === productType)
|
||||
.filter((p) => {
|
||||
return !!p.dimensions;
|
||||
});
|
||||
|
||||
function doOnProductSelected(product: Product) {
|
||||
setActiveProduct(product);
|
||||
@ -21,8 +30,8 @@ export default function ProductList({ onProductSelected }: ProductSelectionProps
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView scrollToOverflowEnabled={true}>
|
||||
{products.map(product => {
|
||||
<ScrollView style={styles.productSelectorFlatList} contentContainerStyle={styles.content} aria-label="product list">
|
||||
{products.map((product) => {
|
||||
return (
|
||||
<ProductTile
|
||||
product={product}
|
||||
@ -33,14 +42,19 @@ export default function ProductList({ onProductSelected }: ProductSelectionProps
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
productSelectorFlatList: {
|
||||
padding: 10,
|
||||
margin: 10,
|
||||
height: windowDimensions.height - 200,
|
||||
width: windowDimensions.width,
|
||||
},
|
||||
|
||||
})
|
||||
content: {
|
||||
alignItems: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
flexDirection: "row",
|
||||
}
|
||||
});
|
||||
|
@ -1,55 +1,84 @@
|
||||
import { Product } from "@/lib/product"
|
||||
import { ImageBackground, StyleProp, StyleSheet, Text, TouchableHighlight, View, ViewStyle } from "react-native";
|
||||
import { Product, priceDisplay, pricePerUnitDisplay } from "@/lib/product";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import {
|
||||
ImageBackground,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableHighlight,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native";
|
||||
import { AnimatedStyle } from "react-native-reanimated";
|
||||
|
||||
export type OnProductSelectedFunc = (product : Product) => any;
|
||||
export type OnProductSelectedFunc = (product: Product) => any;
|
||||
|
||||
type MyStyle = StyleProp<AnimatedStyle<StyleProp<ViewStyle>>>;
|
||||
|
||||
type StyleSpec = {
|
||||
highlight?: MyStyle,
|
||||
text?: MyStyle,
|
||||
image?: MyStyle,
|
||||
}
|
||||
|
||||
highlight?: MyStyle;
|
||||
text?: MyStyle;
|
||||
image?: MyStyle;
|
||||
};
|
||||
|
||||
export type ProductTileProps = {
|
||||
product: (Product),
|
||||
onProductSelected?: OnProductSelectedFunc,
|
||||
isActive: boolean,
|
||||
}
|
||||
product: Product;
|
||||
onProductSelected?: OnProductSelectedFunc;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const FALLBACK_IMAGE = "";
|
||||
|
||||
export function ProductTile ({product, onProductSelected, isActive} : ProductTileProps) {
|
||||
const k = isActive ? "active" : "default";
|
||||
return (
|
||||
export function ProductTile({
|
||||
product,
|
||||
onProductSelected,
|
||||
isActive,
|
||||
}: ProductTileProps) {
|
||||
const k = isActive ? "active" : "default";
|
||||
|
||||
<TouchableHighlight
|
||||
style={styles[k].highlight}
|
||||
onPress={() => onProductSelected && onProductSelected(product)}>
|
||||
<Text style={styles[k].text}>{product.attributes.name || `Product ${product.id}`} ({product.pricePerUnitDisplay})</Text>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
const BLUE_HILIGHT = "#caceff";
|
||||
const BLUE = "#8b9cff";
|
||||
const GRAY_HILIGHT = "#ffffff";
|
||||
const GRAY = "#b1b1b1";
|
||||
|
||||
const activeColors = [BLUE_HILIGHT, BLUE, BLUE, BLUE];
|
||||
const inactiveColors = [GRAY_HILIGHT, GRAY, GRAY, GRAY];
|
||||
|
||||
const priceDisplay = pricePerUnitDisplay(product);
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={isActive ? activeColors : inactiveColors}
|
||||
style={styles.gradientButton}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
aria-label={`product ${product.id}`}
|
||||
onPress={() => onProductSelected && onProductSelected(product)}
|
||||
>
|
||||
<Text style={styles.text}>
|
||||
{product.attributes?.name || `Product ${product.id}`} ({priceDisplay})
|
||||
</Text>
|
||||
</Pressable>
|
||||
</LinearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
active: StyleSheet.create({
|
||||
highlight: {
|
||||
padding: 10,
|
||||
margin: 2,
|
||||
color: "lightblue",
|
||||
},
|
||||
text: {
|
||||
}
|
||||
}),
|
||||
default: StyleSheet.create({
|
||||
highlight: {
|
||||
padding: 10,
|
||||
margin: 2,
|
||||
backgroundColor: "lightgrey",
|
||||
},
|
||||
text: {
|
||||
}
|
||||
}),
|
||||
}
|
||||
const styles = StyleSheet.create({
|
||||
gradientButton: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "gray",
|
||||
borderStyle: "solid",
|
||||
margin: 1,
|
||||
width: 300,
|
||||
marginVertical: 10,
|
||||
marginHorizontal: 10,
|
||||
},
|
||||
button: {
|
||||
},
|
||||
text: {
|
||||
paddingVertical: 30,
|
||||
paddingHorizontal: 40,
|
||||
}
|
||||
});
|
||||
|
@ -1,49 +1,95 @@
|
||||
import { Length } from "convert";
|
||||
import { useState } from "react";
|
||||
import { Button, StyleSheet, View } from "react-native";
|
||||
import {
|
||||
Button,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableHighlight,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
|
||||
export type UnitChooserProps = {
|
||||
choices: Length[],
|
||||
onChoicePressed: (l: Length) => any,
|
||||
activeColor?: string,
|
||||
defaultColor?: string,
|
||||
}
|
||||
export type UnitChooserPropsBase = {
|
||||
onUnitSet?: (l: Length) => any;
|
||||
activeColor?: string;
|
||||
defaultColor?: string;
|
||||
defaultUnit?: Length;
|
||||
};
|
||||
|
||||
export default function UnitChooser({ choices, onChoicePressed, activeColor, defaultColor }: UnitChooserProps) {
|
||||
const [value, setValue] = useState(choices[0] as Length);
|
||||
export type UnitChooserProps = UnitChooserPropsBase & {
|
||||
choices: Length[];
|
||||
};
|
||||
|
||||
activeColor = activeColor || "lightblue";
|
||||
defaultColor = defaultColor || "lightgrey";
|
||||
export default function UnitChooser({
|
||||
choices,
|
||||
onUnitSet,
|
||||
activeColor,
|
||||
defaultColor,
|
||||
defaultUnit,
|
||||
}: UnitChooserProps) {
|
||||
const [value, setValue] = useState(defaultUnit || (choices[0] as Length));
|
||||
|
||||
function doChoiceClicked(choice: Length) {
|
||||
setValue(choice);
|
||||
onChoicePressed(choice);
|
||||
}
|
||||
activeColor = activeColor || "lightblue";
|
||||
defaultColor = defaultColor || "lightgrey";
|
||||
|
||||
return (
|
||||
<View style={styles.unitChooser}>
|
||||
{choices.map((ci) => {
|
||||
return (
|
||||
<Button
|
||||
title={ci}
|
||||
onPress={() => doChoiceClicked(ci)}
|
||||
color={value === ci ? activeColor : defaultColor}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</View>
|
||||
)
|
||||
function doChoiceClicked(choice: Length) {
|
||||
setValue(choice);
|
||||
onUnitSet && onUnitSet(choice);
|
||||
}
|
||||
|
||||
const BLUE_HILIGHT = "#caceff";
|
||||
const BLUE = "#8b9cff";
|
||||
const GRAY_HILIGHT = "#ffffff";
|
||||
const GRAY = "#b1b1b1";
|
||||
|
||||
const activeColors = [BLUE_HILIGHT, BLUE, BLUE, BLUE];
|
||||
const inactiveColors = [GRAY_HILIGHT, GRAY, GRAY, GRAY];
|
||||
|
||||
return (
|
||||
<View style={styles.unitChooser}>
|
||||
{choices.map((ci) => {
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={ci === value ? activeColors : inactiveColors}
|
||||
style={styles.gradientButton}
|
||||
>
|
||||
<TouchableHighlight style={{padding: 5, }} onPress={() => doChoiceClicked(ci)} key={ci}>
|
||||
<Text style={{padding: 5, fontSize: 16}}>{ci}</Text>
|
||||
</TouchableHighlight>
|
||||
</LinearGradient>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
unitChooser: {
|
||||
|
||||
},
|
||||
active: {
|
||||
|
||||
},
|
||||
default: {
|
||||
|
||||
}
|
||||
})
|
||||
gradientButton: {
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "gray",
|
||||
borderStyle: "solid",
|
||||
margin: 1,
|
||||
},
|
||||
unitChooser: {
|
||||
flexDirection: "row",
|
||||
verticalAlign: "middle",
|
||||
padding: 4,
|
||||
},
|
||||
textActive: {
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
fontSize: 25,
|
||||
},
|
||||
textDefault: {
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
fontSize: 25,
|
||||
},
|
||||
unitButton: {},
|
||||
});
|
||||
|
31
components/__tests__/AreaRugTag-test.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
import { AreaRugTag } from '@/components/AreaRugTag';
|
||||
import { area_t } from '@/lib/dimensions';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import initialProducts from '@/__fixtures__/initialProducts';
|
||||
import { Product } from '@/lib/product';
|
||||
|
||||
describe('AreaRugTag', () => {
|
||||
it('renders correctly with dimensions, price per area, date and currency symbol', () => {
|
||||
const dimensions: area_t = { l: 10, w: 20, u: 'ft' };
|
||||
const date = dayjs();
|
||||
const currencySymbol = '$';
|
||||
|
||||
const product = initialProducts.find(p => "area_rug" === p.type) as Product;
|
||||
|
||||
render(
|
||||
<AreaRugTag
|
||||
dimensions={dimensions}
|
||||
product={product}
|
||||
date={date}
|
||||
currencySymbol={currencySymbol}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(`${dimensions.l} x ${dimensions.w}`)).toBeTruthy();
|
||||
// expect(screen.getByLabelText('area rug price')).toContain(currencySymbol);
|
||||
expect(screen.getByText(date.format('YYYY/MM/DD'))).toBeTruthy();
|
||||
expect(screen.getByLabelText('this week\'s color')).toBeTruthy();
|
||||
});
|
||||
});
|
64
components/__tests__/CarpetRollCalculator-test.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
screen,
|
||||
within,
|
||||
act,
|
||||
} from "@testing-library/react-native";
|
||||
import CarpetRollCalculator from "@/components/CarpetRollCalculator";
|
||||
import { renderWithProviders } from "@/lib/rendering";
|
||||
|
||||
import allProducts from "@/__fixtures__/initialProducts";
|
||||
import { Product, pricePerUnitDisplay } from "@/lib/product";
|
||||
import initialProducts from "@/__fixtures__/initialProducts";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const areaRugProducts = allProducts.filter((p) => "area_rug" === p.type);
|
||||
|
||||
describe("CarpetRollCalculator", () => {
|
||||
it("should render correctly", () => {
|
||||
renderWithProviders(<CarpetRollCalculator />, {
|
||||
products: initialProducts,
|
||||
});
|
||||
|
||||
const areaRug = initialProducts.find(
|
||||
(p) => p.type === "area_rug"
|
||||
) as Product;
|
||||
const areaRugLabel = `product ${areaRug.id}`;
|
||||
act(() => {
|
||||
fireEvent.press(screen.getByLabelText(areaRugLabel));
|
||||
});
|
||||
|
||||
// Test the interaction with the width input
|
||||
const widthInput = screen.getByLabelText("width");
|
||||
act(() => {
|
||||
fireEvent.changeText(widthInput, "10");
|
||||
});
|
||||
|
||||
// Test the interaction with the outer diameter input
|
||||
const outerDiameterInput = screen.getByLabelText("outer diameter");
|
||||
act(() => {
|
||||
fireEvent.changeText(outerDiameterInput, "3");
|
||||
});
|
||||
|
||||
// Test the interaction with the inner diameter input
|
||||
const innerDiameterInput = screen.getByLabelText("inner diameter");
|
||||
act(() => {
|
||||
fireEvent.changeText(innerDiameterInput, "1");
|
||||
});
|
||||
|
||||
// Test the interaction with the number of rings input
|
||||
const numRingsInput = screen.getByLabelText("number of rings");
|
||||
act(() => {
|
||||
fireEvent.changeText(numRingsInput, "5");
|
||||
});
|
||||
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
// Test the interaction with the price display
|
||||
const { getByText } = within(screen.getByLabelText("area rug price"));
|
||||
expect(getByText(/\$.*58.*\..*19.*/)).toBeTruthy();
|
||||
});
|
||||
});
|
@ -1,55 +1,51 @@
|
||||
import { Product } from "@/lib/product"
|
||||
import {ProductAttributeEditor} from "../ProductAttributeEditor"
|
||||
import { area } from "enheter"
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import React from "react";
|
||||
import { emitTypingEvents } from "@testing-library/react-native/build/user-event/type/type";
|
||||
import { LumberProduct, Product } from "@/lib/product";
|
||||
import { ProductAttributeEditor } from "../ProductAttributeEditor";
|
||||
import { fireEvent, render, screen } from "@testing-library/react-native";
|
||||
import { renderWithProviders } from "@/lib/rendering";
|
||||
|
||||
describe("Product editor tests", () => {
|
||||
const productName = "Fun Product";
|
||||
it("Product attributes can be deleted", async () => {
|
||||
const product = new Product(
|
||||
100,
|
||||
{l: 100, u: "foot"},
|
||||
{"name" : productName}
|
||||
);
|
||||
const onChange = jest.fn();
|
||||
const onDelete = jest.fn();
|
||||
render(
|
||||
<ProductAttributeEditor
|
||||
attributeKey="name"
|
||||
attributeValue="product"
|
||||
product={product}
|
||||
onChangeAttribute={onChange}
|
||||
onDelete={onDelete}
|
||||
/>);
|
||||
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
|
||||
fireEvent.press(await screen.getByLabelText("Delete Attribute"));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
it("Product attributes can be modified", async () => {
|
||||
const product = new Product(
|
||||
100,
|
||||
{l: 100, u: "foot"},
|
||||
{"name" : productName}
|
||||
);
|
||||
const onChange = jest.fn();
|
||||
const onDelete = jest.fn();
|
||||
const onKeyChange = jest.fn();
|
||||
render(
|
||||
<ProductAttributeEditor
|
||||
attributeKey="old test key"
|
||||
attributeValue="old test value"
|
||||
onChangeAttribute={onChange}
|
||||
onDelete={onDelete}
|
||||
onChangeAttributeKey={onKeyChange}
|
||||
/>);
|
||||
fireEvent.changeText(screen.getByLabelText("Edit Key"), "new test key");
|
||||
expect(onKeyChange).toHaveBeenCalled();
|
||||
fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
fireEvent.press(screen.getByLabelText("Delete Attribute"));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
})
|
||||
|
||||
})
|
||||
const productName = "Fun Product";
|
||||
it("Product attributes can be deleted", async () => {
|
||||
const onChange = jest.fn();
|
||||
const onDelete = jest.fn();
|
||||
renderWithProviders(
|
||||
<ProductAttributeEditor
|
||||
attributeKey="name"
|
||||
attributeValue="product"
|
||||
onChangeAttribute={onChange}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText("Delete Attribute")).not.toBeNull();
|
||||
fireEvent.press(await screen.getByLabelText("Delete Attribute"));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
it("Product attributes can be modified", async () => {
|
||||
const product: Product = {
|
||||
pricePerUnit: 10,
|
||||
dimensions: {
|
||||
l: 40,
|
||||
u: "ft",
|
||||
},
|
||||
type: "lumber",
|
||||
};
|
||||
const onChange = jest.fn();
|
||||
const onDelete = jest.fn();
|
||||
const onKeyChange = jest.fn();
|
||||
render(
|
||||
<ProductAttributeEditor
|
||||
attributeKey="old test key"
|
||||
attributeValue="old test value"
|
||||
onChangeAttribute={onChange}
|
||||
onDelete={onDelete}
|
||||
onChangeAttributeKey={onKeyChange}
|
||||
/>
|
||||
);
|
||||
fireEvent.changeText(screen.getByLabelText("Edit Key"), "new test key");
|
||||
expect(onKeyChange).toHaveBeenCalled();
|
||||
fireEvent.changeText(screen.getByLabelText("Edit Value"), "new name");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
fireEvent.press(screen.getByLabelText("Delete Attribute"));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -2,34 +2,28 @@ import { render, fireEvent, screen, act, within } from '@testing-library/react-n
|
||||
import { Provider } from 'react-redux';
|
||||
import ProductCalculatorSelector from '@/components/ProductCalculatorSelector';
|
||||
import { renderWithProviders } from '@/lib/rendering';
|
||||
import { Product } from '@/lib/product';
|
||||
import { Product, pricePerUnitDisplay, productPriceFor } from '@/lib/product';
|
||||
import initialProducts from '@/__fixtures__/initialProducts';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockAreaProduct = initialProducts.find(p => 'w' in p.dimensions ) as Product
|
||||
const mockLengthProduct = initialProducts.find(p => (!('w' in p.dimensions)) ) as Product
|
||||
|
||||
describe('ProductCalculatorSelector', () => {
|
||||
|
||||
const mockAreaProduct = new Product(
|
||||
100,
|
||||
{ l: 4, w: 8, u: "ft" },
|
||||
{"name": "area product"},
|
||||
);
|
||||
const mockLengthProduct = new Product(
|
||||
100,
|
||||
{ l: 4, u: "ft" },
|
||||
{"name": "length product"},
|
||||
);
|
||||
|
||||
it('renders correctly', () => {
|
||||
renderWithProviders(
|
||||
(<ProductCalculatorSelector />),
|
||||
{
|
||||
products: [
|
||||
mockAreaProduct.asObject,
|
||||
mockLengthProduct.asObject,
|
||||
mockAreaProduct,
|
||||
mockLengthProduct,
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
expect(screen.getByText('Please select a product')).toBeTruthy();
|
||||
const label = `${mockAreaProduct.attributes.name} (${mockAreaProduct.pricePerUnitDisplay})`;
|
||||
const label = `${mockAreaProduct.attributes?.name} (${pricePerUnitDisplay(mockAreaProduct)})`;
|
||||
expect(screen.getByText(label)).toBeTruthy();
|
||||
});
|
||||
|
||||
@ -38,23 +32,26 @@ describe('ProductCalculatorSelector', () => {
|
||||
(<ProductCalculatorSelector />),
|
||||
{
|
||||
products: [
|
||||
mockLengthProduct.asObject,
|
||||
mockAreaProduct.asObject,
|
||||
mockLengthProduct,
|
||||
mockAreaProduct,
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
expect(screen.getByText('Please select a product')).toBeTruthy();
|
||||
const areaLabel = `${mockAreaProduct.attributes.name} (${mockAreaProduct.pricePerUnitDisplay})`;
|
||||
const lengthLabel = `${mockLengthProduct.attributes.name} (${mockLengthProduct.pricePerUnitDisplay})`;
|
||||
const areaLabel = `${mockAreaProduct.attributes?.name} (${pricePerUnitDisplay(mockAreaProduct)})`;
|
||||
|
||||
fireEvent.press(screen.getByText(areaLabel));
|
||||
act(()=>{
|
||||
fireEvent.press(screen.getByText(areaLabel));
|
||||
})
|
||||
const lengthInput = screen.getByLabelText("enter length");
|
||||
const widthInput = screen.getByLabelText("enter length");
|
||||
expect(lengthInput).toBeTruthy();
|
||||
expect(widthInput).toBeTruthy();
|
||||
|
||||
fireEvent.press(screen.getByText("in"));
|
||||
act(() => {
|
||||
fireEvent.press(screen.getByText("in"));
|
||||
})
|
||||
|
||||
act(() => {
|
||||
fireEvent.changeText(lengthInput, "2");
|
||||
@ -63,10 +60,10 @@ describe('ProductCalculatorSelector', () => {
|
||||
|
||||
jest.advanceTimersByTime(3000);
|
||||
|
||||
const price = mockAreaProduct.priceFor({l: 2, w: 4, u: "ft"});
|
||||
const price = productPriceFor(mockAreaProduct, {l: 2, w: 4, u: "ft"})
|
||||
const sPrice = price.toLocaleString(undefined, {maximumFractionDigits: 2, minimumFractionDigits: 2,});
|
||||
const element = screen.getByLabelText("calculated price");
|
||||
const {getByText} = within(element);
|
||||
expect(getByText(sPrice)).toBeTruthy();
|
||||
expect(getByText(/\$.*15.*\.00/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
@ -2,50 +2,54 @@ import { renderWithProviders } from "@/lib/rendering";
|
||||
import { ProductEditor } from "@/components/ProductEditor";
|
||||
import { act, fireEvent, screen } from "@testing-library/react-native";
|
||||
import { selectProducts } from "@/features/product/productSlice";
|
||||
import { Product } from "@/lib/product";
|
||||
import { LumberProduct, Product, productLabel } from "@/lib/product";
|
||||
|
||||
import initialProducts from "@/__fixtures__/initialProducts";
|
||||
|
||||
describe("ProductEditor", () => {
|
||||
const productName = "Flooring"
|
||||
const mockProduct = new Product(
|
||||
25,
|
||||
{ l: 4, w: 8, u: "foot" },
|
||||
{ name: productName },
|
||||
)
|
||||
it("renders correctly", async () => {
|
||||
const { store } = renderWithProviders(<ProductEditor />, {
|
||||
products: [
|
||||
mockProduct.asObject,
|
||||
],
|
||||
});
|
||||
|
||||
const state1 = store.getState();
|
||||
|
||||
let products = selectProducts(state1);
|
||||
|
||||
expect(products).toHaveLength(1);
|
||||
|
||||
// Check if the product names are rendered
|
||||
expect(screen.getByText(products[0].attributes.name as string)).toBeTruthy();
|
||||
|
||||
// Start to edit a product
|
||||
fireEvent.press(screen.getByText(productName));
|
||||
|
||||
// Change properties of the product to make sure it's updated in the store
|
||||
|
||||
act(() => {
|
||||
fireEvent.changeText(screen.getByLabelText("length"), "16");
|
||||
})
|
||||
products = selectProducts(store.getState());
|
||||
expect(products[0].dimensions.l).toBe(16);
|
||||
act(() => {
|
||||
fireEvent.changeText(screen.getByLabelText("width"), "32");
|
||||
})
|
||||
products = selectProducts(store.getState());
|
||||
|
||||
expect(products[0].dimensions.w).toBe(32);
|
||||
|
||||
fireEvent.press(screen.getByLabelText("delete product"));
|
||||
products = selectProducts(store.getState());
|
||||
expect(products.length).toBe(0);
|
||||
const productName = "Flooring";
|
||||
const mockProduct = initialProducts[0];
|
||||
it("renders correctly", async () => {
|
||||
const { store } = renderWithProviders(<ProductEditor />, {
|
||||
products: [mockProduct],
|
||||
});
|
||||
|
||||
const state1 = store.getState();
|
||||
|
||||
let products = selectProducts(state1);
|
||||
|
||||
expect(products).toHaveLength(1);
|
||||
|
||||
// Check if the product names are rendered
|
||||
expect(
|
||||
screen.getByText(mockProduct.attributes?.name as string)
|
||||
).toBeTruthy();
|
||||
|
||||
const label = productLabel(mockProduct);
|
||||
|
||||
// Start to edit a product
|
||||
act(() => {
|
||||
fireEvent.press(screen.getByText(label));
|
||||
})
|
||||
|
||||
// Change properties of the product to make sure it's updated in the store
|
||||
|
||||
act(() => {
|
||||
fireEvent.changeText(screen.getByLabelText("length"), "16");
|
||||
});
|
||||
products = selectProducts(store.getState());
|
||||
expect(products[0].dimensions.l).toBe(16);
|
||||
act(() => {
|
||||
fireEvent.changeText(screen.getByLabelText("width"), "32");
|
||||
});
|
||||
products = selectProducts(store.getState());
|
||||
|
||||
expect(products[0].dimensions.w).toBe(32);
|
||||
|
||||
act(() => {
|
||||
fireEvent.press(screen.getByLabelText("delete product"));
|
||||
})
|
||||
products = selectProducts(store.getState());
|
||||
expect(products.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
@ -4,14 +4,22 @@ import { ProductEditorItem } from '../ProductEditorItem';
|
||||
import { Product } from '@/lib/product';
|
||||
import { area } from 'enheter';
|
||||
import { renderWithProviders } from '@/lib/rendering';
|
||||
import { area_t } from '@/lib/dimensions';
|
||||
|
||||
describe('ProductEditorItem', () => {
|
||||
const productName = "Product 1";
|
||||
const mockProduct = new Product(
|
||||
25,
|
||||
{l: 4, u: 'feet'},
|
||||
{"name": productName},
|
||||
)
|
||||
const mockProduct : Product = {
|
||||
type: "area_rug",
|
||||
dimensions: {
|
||||
l: 1,
|
||||
w: 1,
|
||||
u: "feet",
|
||||
},
|
||||
pricePerUnit: 0.75,
|
||||
attributes: {
|
||||
name: productName,
|
||||
}
|
||||
}
|
||||
|
||||
const onAttributeAdded = jest.fn();
|
||||
const mockOnProductDeleted = jest.fn();
|
||||
@ -56,7 +64,7 @@ describe('ProductEditorItem', () => {
|
||||
}
|
||||
);
|
||||
fireEvent.press(screen.getByText("Product 1"));
|
||||
expect(screen.getByLabelText("units")).toBeTruthy();
|
||||
// expect(screen.getByLabelText("Units")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Edit Key")).toBeTruthy();
|
||||
expect(screen.getAllByLabelText("Edit Value").length).toEqual(1);
|
||||
|
||||
|
38
components/__tests__/ProductList-test.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { renderWithProviders } from '@/lib/rendering';
|
||||
import { Product, pricePerUnitDisplay } from '@/lib/product';
|
||||
import ProductList from '@/components/ProductList';
|
||||
import initialProducts from '@/__fixtures__/initialProducts';
|
||||
import { screen } from '@testing-library/react-native';
|
||||
|
||||
describe('ProductList', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { getByTestId } = renderWithProviders(<ProductList />, {
|
||||
products: initialProducts,
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('product list')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders products correctly', () => {
|
||||
const mockProduct = initialProducts[0] as Product;
|
||||
const label = `${mockProduct.attributes?.name} (${pricePerUnitDisplay(mockProduct)})`;
|
||||
|
||||
const { getByText } = renderWithProviders(<ProductList />, {
|
||||
products: [mockProduct],
|
||||
});
|
||||
|
||||
expect(getByText(label)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders only area_rug products', () => {
|
||||
const areaRug = initialProducts.find(p => p.type == "area_rug") as Product;
|
||||
const label = `${areaRug?.attributes?.name} (${pricePerUnitDisplay(areaRug)})`;
|
||||
|
||||
renderWithProviders(<ProductList productType='area_rug' />, {
|
||||
products: initialProducts,
|
||||
});
|
||||
|
||||
expect(screen.getByText(label)).toBeTruthy();
|
||||
});
|
||||
});
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react-native';
|
||||
import UnitChooser from "../UnitChooser";
|
||||
import { Length } from 'safe-units';
|
||||
import { Length } from 'convert';
|
||||
|
||||
describe('UnitChooser', () => {
|
||||
const mockOnChoicePressed = jest.fn();
|
||||
@ -9,7 +9,7 @@ describe('UnitChooser', () => {
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { getByText } = render(
|
||||
<UnitChooser choices={choices} onChoicePressed={mockOnChoicePressed} />
|
||||
<UnitChooser choices={choices} onUnitSet={mockOnChoicePressed} />
|
||||
);
|
||||
|
||||
choices.forEach(choice => {
|
||||
@ -19,7 +19,7 @@ describe('UnitChooser', () => {
|
||||
|
||||
it('calls onChoicePressed when a button is pressed', () => {
|
||||
const { getByText } = render(
|
||||
<UnitChooser choices={choices} onChoicePressed={mockOnChoicePressed} />
|
||||
<UnitChooser choices={choices} onUnitSet={mockOnChoicePressed} />
|
||||
);
|
||||
|
||||
fireEvent.press(getByText(choices[0]));
|
||||
|
BIN
doc/images/icons/list.png
Normal file
After Width: | Height: | Size: 722 B |
BIN
doc/images/icons/scale.png
Normal file
After Width: | Height: | Size: 980 B |
BIN
doc/images/screenshots/house-siding-length-input-feet.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
doc/images/screenshots/index.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
doc/images/screenshots/plywood-sheet-4-by-8-feet-25-damage.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
doc/images/screenshots/plywood-sheet-4-by-8-feet.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
doc/images/screenshots/plywood-sheet-4-by-8-inches.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
doc/images/screenshots/product-editor.png
Normal file
After Width: | Height: | Size: 32 KiB |
11
eas.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.0.3"
|
||||
"version": ">= 10.1.0"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
@ -8,9 +8,16 @@
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {}
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
|
@ -1,185 +1,274 @@
|
||||
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { area_t, dimensions_t, Id, length_t, Product, ProductData } from '@/lib/product';
|
||||
import { createSelector, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { attributesAsList, Id, Product } from "@/lib/product";
|
||||
import { dimensions_t, length_t } from "@/lib/dimensions";
|
||||
import uuid from "react-native-uuid";
|
||||
import { RootState } from '@/app/store';
|
||||
import { classToPlain, plainToClass } from 'class-transformer';
|
||||
import { AppStore, RootState } from "@/app/store";
|
||||
import { Length } from "convert";
|
||||
|
||||
const initialState = {
|
||||
products: [] as ProductData[],
|
||||
}
|
||||
export const DEFAULT_UNITS: Length = "ft";
|
||||
|
||||
export type PlywoodCalculationState = {
|
||||
product?: Id | null;
|
||||
units: Length;
|
||||
};
|
||||
|
||||
export type CarpetCalculationState = {
|
||||
product?: Id | null;
|
||||
length: length_t;
|
||||
inner_d: length_t;
|
||||
outer_d: length_t;
|
||||
};
|
||||
|
||||
export type AppStoreState = {
|
||||
products: Product[];
|
||||
calculations?: {
|
||||
plywood: PlywoodCalculationState;
|
||||
carpet: CarpetCalculationState;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_PRELOADED_STATE = {
|
||||
products: [] as Product[],
|
||||
calculations: {
|
||||
plywood: {
|
||||
product: undefined,
|
||||
units: DEFAULT_UNITS,
|
||||
},
|
||||
carpet: {
|
||||
product: undefined,
|
||||
length: {
|
||||
l: 0,
|
||||
u: DEFAULT_UNITS,
|
||||
},
|
||||
inner_d: {
|
||||
l: 0,
|
||||
u: DEFAULT_UNITS,
|
||||
},
|
||||
outer_d: {
|
||||
l: 0,
|
||||
u: DEFAULT_UNITS,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as AppStoreState;
|
||||
|
||||
const initialState: AppStoreState = DEFAULT_PRELOADED_STATE;
|
||||
|
||||
export type UpdateAttribute = {
|
||||
product_id: Id,
|
||||
attributeKey: string,
|
||||
attributeValue: any,
|
||||
}
|
||||
product_id: Id;
|
||||
attributeKey: string;
|
||||
attributeValue: any;
|
||||
};
|
||||
|
||||
export type UpdateAttributeKey = {
|
||||
product_id: Id,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
}
|
||||
product_id: Id;
|
||||
oldKey: string;
|
||||
newKey: string;
|
||||
};
|
||||
|
||||
export type AddAttribute = {
|
||||
product_id: Id,
|
||||
}
|
||||
product_id: Id;
|
||||
};
|
||||
|
||||
const cp = (obj: any) => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
const productsState = createSlice({
|
||||
name: 'products-slice',
|
||||
initialState,
|
||||
reducers: {
|
||||
createProduct(state, action: PayloadAction<ProductData>) {
|
||||
if (!state) {
|
||||
return initialState
|
||||
}
|
||||
const product = action.payload;
|
||||
if (!product.id) product.id = uuid.v4().toString();
|
||||
state.products = [...state.products, action.payload];
|
||||
return state;
|
||||
},
|
||||
deleteProduct(state, action: PayloadAction<Id>) {
|
||||
if (!state) return initialState;
|
||||
return {
|
||||
...state,
|
||||
products: [...state.products.filter((prod) => {
|
||||
return prod.id?.valueOf() !== action.payload.valueOf();
|
||||
})],
|
||||
}
|
||||
},
|
||||
updateAttribute(state, action: PayloadAction<UpdateAttribute>) {
|
||||
const { product_id, attributeKey, attributeValue } = action.payload
|
||||
if (!state) return initialState;
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map(prod => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
const attributes = cp(prod.attributes);
|
||||
attributes[attributeKey] = attributeValue;
|
||||
return {
|
||||
...prod,
|
||||
attributes,
|
||||
}
|
||||
})
|
||||
};
|
||||
},
|
||||
changeKey(state, action: PayloadAction<UpdateAttributeKey>) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, oldKey, newKey } = action.payload
|
||||
name: "products-slice",
|
||||
initialState,
|
||||
reducers: {
|
||||
setPlywoodCalculation(
|
||||
state,
|
||||
action: PayloadAction<PlywoodCalculationState>
|
||||
) {
|
||||
if (!state) {
|
||||
return initialState;
|
||||
}
|
||||
const newCalc = action.payload;
|
||||
state.calculations.plywood = newCalc;
|
||||
return state;
|
||||
},
|
||||
setCarpetCalculation(state, action: PayloadAction<CarpetCalculationState>) {
|
||||
if (!state) {
|
||||
return initialState;
|
||||
}
|
||||
const newCalc = action.payload;
|
||||
state.calculations.carpet = newCalc;
|
||||
return state;
|
||||
},
|
||||
createProduct(state, action: PayloadAction<Product>) {
|
||||
if (!state) {
|
||||
return initialState;
|
||||
}
|
||||
const product = action.payload;
|
||||
if (!product.id) product.id = uuid.v4().toString();
|
||||
state.products = [...state.products, action.payload];
|
||||
return state;
|
||||
},
|
||||
deleteProduct(state, action: PayloadAction<Id>) {
|
||||
if (!state) return initialState;
|
||||
return {
|
||||
...state,
|
||||
products: [
|
||||
...state.products.filter((prod) => {
|
||||
return prod.id?.valueOf() !== action.payload.valueOf();
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
updateAttribute(state, action: PayloadAction<UpdateAttribute>) {
|
||||
const { product_id, attributeKey, attributeValue } = action.payload;
|
||||
if (!state) return initialState;
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map((prod) => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
const attributes = cp(prod.attributes);
|
||||
attributes[attributeKey] = attributeValue;
|
||||
return {
|
||||
...prod,
|
||||
attributes,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
changeKey(state, action: PayloadAction<UpdateAttributeKey>) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, oldKey, newKey } = action.payload;
|
||||
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map(prod => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map((prod) => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
|
||||
const attributes = cp(prod.attributes);
|
||||
attributes[newKey] = attributes[oldKey];
|
||||
delete attributes[oldKey];
|
||||
attributes.id = prod.id;
|
||||
return {
|
||||
...prod,
|
||||
attributes,
|
||||
}
|
||||
})
|
||||
};
|
||||
},
|
||||
addAttribute(state, action: PayloadAction<Id>) {
|
||||
if (!state) return initialState;
|
||||
const product_id = action.payload;
|
||||
state.products = state.products.map(prod => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
const i = (Object.keys(prod.attributes || {}).filter(k => k.match(/attribute [\d]+/)) || []).length;
|
||||
const newAttribute = `attribute ${i + 1}`;
|
||||
return {
|
||||
...prod,
|
||||
attributes: {
|
||||
...prod.attributes,
|
||||
[newAttribute]: `value`,
|
||||
}
|
||||
}
|
||||
});
|
||||
return state;
|
||||
},
|
||||
const attributes = cp(prod.attributes);
|
||||
attributes[newKey] = attributes[oldKey];
|
||||
delete attributes[oldKey];
|
||||
attributes.id = prod.id;
|
||||
return {
|
||||
...prod,
|
||||
attributes,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
addAttribute(state, action: PayloadAction<Id>) {
|
||||
if (!state) return initialState;
|
||||
const product_id = action.payload;
|
||||
state.products = state.products.map((prod) => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
const i = (
|
||||
Object.keys(prod.attributes || {}).filter((k) =>
|
||||
k.match(/attribute [\d]+/)
|
||||
) || []
|
||||
).length;
|
||||
const newAttribute = `attribute ${i + 1}`;
|
||||
return {
|
||||
...prod,
|
||||
attributes: {
|
||||
...prod.attributes,
|
||||
[newAttribute]: `value`,
|
||||
},
|
||||
};
|
||||
});
|
||||
return state;
|
||||
},
|
||||
|
||||
deleteAttribute(state, action: PayloadAction<{ product_id: Id, attribute: string }>) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, attribute } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map(prod => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
const attributes = Object.fromEntries(Object.entries(prod).filter(([k, v]) => (k !== attribute)));
|
||||
return {
|
||||
...prod,
|
||||
attributes,
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
updatePrice(state, action: PayloadAction<{ product_id: Id, pricePerUnit: number }>) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, pricePerUnit } = action.payload;
|
||||
state.products = state.products.map(prod => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
prod.pricePerUnit = pricePerUnit;
|
||||
return prod;
|
||||
});
|
||||
return state;
|
||||
},
|
||||
updateDimensions(state, action: PayloadAction<{ product_id: Id, dimensions: dimensions_t }>) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, dimensions } = action.payload;
|
||||
console.log("Changing dimensions: %o", action.payload);
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map(prod => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
return {
|
||||
...prod,
|
||||
dimensions,
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
}
|
||||
deleteAttribute(
|
||||
state,
|
||||
action: PayloadAction<{ product_id: Id; attribute: string }>
|
||||
) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, attribute } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map((prod) => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
const attributes = Object.fromEntries(
|
||||
Object.entries(prod).filter(([k, v]) => k !== attribute)
|
||||
);
|
||||
return {
|
||||
...prod,
|
||||
attributes,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
updatePrice(
|
||||
state,
|
||||
action: PayloadAction<{ product_id: Id; pricePerUnit: number }>
|
||||
) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, pricePerUnit } = action.payload;
|
||||
state.products = state.products.map((prod) => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
prod.pricePerUnit = pricePerUnit;
|
||||
return prod;
|
||||
});
|
||||
return state;
|
||||
},
|
||||
updateDimensions(
|
||||
state,
|
||||
action: PayloadAction<{ product_id: Id; dimensions: dimensions_t }>
|
||||
) {
|
||||
if (!state) return initialState;
|
||||
const { product_id, dimensions } = action.payload;
|
||||
console.log("Changing dimensions: %o", action.payload);
|
||||
return {
|
||||
...state,
|
||||
products: state.products.map((prod) => {
|
||||
if (prod.id !== product_id) return prod;
|
||||
return {
|
||||
...prod,
|
||||
dimensions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const selectProductsDatas = (state: RootState) => {
|
||||
return state.products;
|
||||
}
|
||||
export const selectProducts = (state: RootState) => {
|
||||
return state.products;
|
||||
};
|
||||
|
||||
export const selectProducts = createSelector([selectProductsDatas], productsData => {
|
||||
return productsData.map(d => Product.fromObject(d));
|
||||
})
|
||||
export const selectPlywoodCalc = (state: RootState) => {
|
||||
return state.calculations.plywood;
|
||||
};
|
||||
|
||||
export const selectProductIds = createSelector([selectProducts], products => {
|
||||
return products.map(p => p.id);
|
||||
})
|
||||
export const selectCarpetCalc = (state: RootState) => {
|
||||
return state.calculations.carpet;
|
||||
};
|
||||
|
||||
export const selectProductAttributes = createSelector([selectProducts], products => {
|
||||
return Object.fromEntries(products.map(p => {
|
||||
return [
|
||||
p.id,
|
||||
p.attributesAsList,
|
||||
]
|
||||
}))
|
||||
})
|
||||
export const selectProductIds = createSelector([selectProducts], (products) => {
|
||||
return products.map((p) => p.id);
|
||||
});
|
||||
|
||||
export const selectProductAttributes = createSelector(
|
||||
[selectProducts],
|
||||
(products) => {
|
||||
return Object.fromEntries(
|
||||
products.map((p) => {
|
||||
return [p.id, p.attributes ? attributesAsList(p.attributes) : []];
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const actions = {
|
||||
...productsState.actions
|
||||
...productsState.actions,
|
||||
};
|
||||
|
||||
export const {
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
changeKey,
|
||||
updateAttribute,
|
||||
addAttribute,
|
||||
deleteAttribute,
|
||||
updatePrice,
|
||||
updateDimensions,
|
||||
setCarpetCalculation,
|
||||
setPlywoodCalculation,
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
changeKey,
|
||||
updateAttribute,
|
||||
addAttribute,
|
||||
deleteAttribute,
|
||||
updatePrice,
|
||||
updateDimensions,
|
||||
} = productsState.actions;
|
||||
|
||||
export default productsState.reducer;
|
||||
|
14
lib/__tests__/dimensions-test.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { diameterToLength, length_t } from '../dimensions';
|
||||
|
||||
describe('diameterToLength', () => {
|
||||
it('should throw an error if the units of the outer and inner diameters do not match', () => {
|
||||
expect(() => diameterToLength({ l: 10, u: 'inch' }, { l: 8, u: 'foot' }, 2)).toThrow('diameter units must match!');
|
||||
});
|
||||
|
||||
it('should return the correct length for multiple rings with different units', () => {
|
||||
const outer : length_t = {l: 25, u: "in"};
|
||||
const inner: length_t = {l : 1, u: "in"};
|
||||
const l = diameterToLength(outer, inner, 12);
|
||||
expect(l.l).toBeCloseTo(490, -1.0);
|
||||
});
|
||||
});
|
@ -1,30 +1,47 @@
|
||||
import { length } from "enheter";
|
||||
import { Product } from "../product";
|
||||
import { Id, Product, productPriceFor } from "../product";
|
||||
import uuid from "react-native-uuid"
|
||||
|
||||
describe("Product tests", () => {
|
||||
|
||||
it(`Length product gives correct price for a shorter length`, () => {
|
||||
const standard = new Product(20, { l: 4, u: "feet" });
|
||||
const comparison = standard.priceFor({ l: 2, u: "feet" });
|
||||
expect(comparison).toEqual(10);
|
||||
const standard : Product = {
|
||||
id: uuid.v4().valueOf() as Id,
|
||||
dimensions: {
|
||||
l: 5,
|
||||
u: "ft",
|
||||
},
|
||||
type: "lumber",
|
||||
pricePerUnit: 10,
|
||||
};
|
||||
const comparison = productPriceFor(standard, { l: 2, u: "feet" });
|
||||
expect(comparison).toEqual(4);
|
||||
});
|
||||
|
||||
it(`Length product gives correct price for a longer length`, () => {
|
||||
const standard = new Product(20, {l: 4, u : "feet"});
|
||||
const comparison = standard.priceFor({l : 8, u : "feet"});
|
||||
expect(comparison).toEqual(40);
|
||||
const standard : Product = {
|
||||
id: uuid.v4().valueOf() as Id,
|
||||
dimensions: {
|
||||
l: 5,
|
||||
u: "ft",
|
||||
},
|
||||
type: "lumber",
|
||||
pricePerUnit: 10,
|
||||
};
|
||||
const comparison = productPriceFor(standard, { l: 10, u: "feet" });
|
||||
expect(comparison).toEqual(20);
|
||||
});
|
||||
|
||||
it(`Length product gives correct price if different units`, () => {
|
||||
const standard = new Product(10, {l: 1, u : "feet"});
|
||||
const comparison = standard.priceFor({l : 24, u: "inch"});
|
||||
expect(comparison).toBeCloseTo(20, 4);
|
||||
const standard : Product = {
|
||||
id: uuid.v4().valueOf() as Id,
|
||||
dimensions: {
|
||||
l: 12,
|
||||
u: "in",
|
||||
},
|
||||
type: "lumber",
|
||||
pricePerUnit: 1,
|
||||
};
|
||||
const comparison = productPriceFor(standard, { l: 2, u: "feet" });
|
||||
expect(comparison).toBeCloseTo(2, 1);
|
||||
});
|
||||
|
||||
it("Can convert to/from object", () => {
|
||||
const standard = new Product(10, {l: 1, u : "feet"});
|
||||
const obj = standard.asObject;
|
||||
const back = Product.fromObject(obj);
|
||||
expect(back).toEqual(standard);
|
||||
})
|
||||
});
|
59
lib/dimensions.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import convert from "convert";
|
||||
import { Length } from "convert";
|
||||
|
||||
export type length_t = {
|
||||
l: number; u: Length;
|
||||
};
|
||||
|
||||
export type area_t = length_t & {
|
||||
w: number;
|
||||
};
|
||||
|
||||
export type dimensions_t = area_t | length_t;
|
||||
|
||||
|
||||
export const isArea = (d: dimensions_t) => ("width" in d);
|
||||
export const isLength = (d: dimensions_t) => (!("width" in d));
|
||||
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"; export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
|
||||
if (!(
|
||||
(isArea(d1) && isArea(d2)) ||
|
||||
(isLength(d1) && isLength(d2))
|
||||
)) {
|
||||
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
l: convert(d1.l, d1.u).to(d2.u),
|
||||
u: d2.u,
|
||||
...(
|
||||
"w" in d1 ?
|
||||
{ w: convert(d1.w, d1.u).to(d2.u), }
|
||||
: {}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
const FACTOR = 0.1309;
|
||||
|
||||
/**
|
||||
* Gets the total length of a carpet roll based on a diameter
|
||||
* @param outerDiameter Outer diameter of the carpet roll
|
||||
* @param innerDiameter Inner diameter of the carpet roll (the "hole")
|
||||
* @param numRings Number of "rings" or "layers,"" just like a tree 🙂 🌲
|
||||
*/
|
||||
export function diameterToLength(outerDiameter: length_t, innerDiameter: length_t, numRings: number) {
|
||||
if (outerDiameter.u !== innerDiameter.u) {
|
||||
throw new Error("diameter units must match!")
|
||||
}
|
||||
const l = FACTOR * numRings * (innerDiameter.l + outerDiameter.l);
|
||||
return {
|
||||
l,
|
||||
u: outerDiameter.u
|
||||
}
|
||||
}
|
||||
|
||||
export function dimensionsDisplay(d : dimensions_t) {
|
||||
if ("w" in d) {
|
||||
return `${d.w} x ${d.l} ${d.u}`
|
||||
}
|
||||
}
|
195
lib/product.ts
@ -1,134 +1,103 @@
|
||||
import uuid from "react-native-uuid";
|
||||
import convert, { Area, Length } from "convert";
|
||||
import { Transform } from "class-transformer";
|
||||
import { dimensions_t, area_t, dimensionsDisplay } from "./dimensions";
|
||||
import { matchDimensions } from "./dimensions";
|
||||
import { Area, Length, Unit } from "convert";
|
||||
|
||||
export type Id = string;
|
||||
|
||||
export type Currency = "USD";
|
||||
|
||||
export type ProductAttributes = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
image?: string,
|
||||
description?: string,
|
||||
depth?: string,
|
||||
currency?: Currency,
|
||||
// [index:string]: any,
|
||||
}
|
||||
export type length_t = {
|
||||
l: number, u: Length
|
||||
}
|
||||
|
||||
export type area_t = length_t & {
|
||||
w: number,
|
||||
}
|
||||
|
||||
export type dimensions_t = area_t | length_t;
|
||||
|
||||
export type ProductData = {
|
||||
id?: Id,
|
||||
pricePerUnit: number,
|
||||
dimensions: dimensions_t,
|
||||
attributes?: ProductAttributes,
|
||||
id?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
description?: string;
|
||||
depth?: string;
|
||||
currency?: Currency;
|
||||
[index: string]: any;
|
||||
};
|
||||
|
||||
|
||||
export type product_type_t = "area" | "length";
|
||||
|
||||
export const isArea = (d: dimensions_t) => ("width" in d);
|
||||
export const isLength = (d: dimensions_t) => (!("width" in d));
|
||||
export const dimensionType = (d: dimensions_t) => isArea(d) ? "area" : "length"
|
||||
|
||||
export function matchDimensions(d1: dimensions_t, d2: dimensions_t) {
|
||||
if (!
|
||||
(
|
||||
(isArea(d1) && isArea(d2)) ||
|
||||
(isLength(d1) && isLength(d2))
|
||||
)
|
||||
) {
|
||||
throw new Error(`Dimension mismatch: ${JSON.stringify(d1)} / ${JSON.stringify(d1)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
l: convert(d1.l, d1.u).to(d2.u),
|
||||
u: d2.u,
|
||||
...(
|
||||
"w" in d1 ?
|
||||
{ w: convert(d1.w, d1.u).to(d2.u), }
|
||||
: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function dimensionArea(d: dimensions_t) {
|
||||
return "w" in d ? d.w * d.l : 0;
|
||||
return "w" in d ? d.w * d.l : 0;
|
||||
}
|
||||
|
||||
export class Product {
|
||||
export const PRODUCT_TYPES = ["lumber", "area_rug"] as const;
|
||||
|
||||
public id?: Id;
|
||||
export type product_type_t = (typeof PRODUCT_TYPES)[number];
|
||||
|
||||
constructor(public pricePerUnit: number, public dimensions: dimensions_t, public attributes: ProductAttributes = {},
|
||||
id?: Id,
|
||||
) {
|
||||
this.id = id || uuid.v4().toString();
|
||||
}
|
||||
export type Product = {
|
||||
id?: Id;
|
||||
pricePerUnit: number;
|
||||
dimensions: dimensions_t;
|
||||
type: product_type_t;
|
||||
attributes?: ProductAttributes;
|
||||
};
|
||||
|
||||
public priceFor(dimensions: dimensions_t, damage : number): number {
|
||||
if (Number.isNaN(damage)) damage = 0;
|
||||
const dim = matchDimensions(dimensions, this.dimensions);
|
||||
return (
|
||||
dim.w ? dimensionArea(dim) / dimensionArea(this.dimensions) * this.pricePerUnit
|
||||
: (dim.l / this.dimensions.l) * this.pricePerUnit
|
||||
) * (1.0 - damage);
|
||||
}
|
||||
export type LumberProduct = Product & {
|
||||
type: "lumber";
|
||||
};
|
||||
|
||||
get priceDisplay() {
|
||||
return this.pricePerUnit.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}
|
||||
export type AreaRugProduct = Product & {
|
||||
type: "lumber";
|
||||
};
|
||||
|
||||
get pricePerUnitDisplay() {
|
||||
const p = this.priceDisplay;
|
||||
const { l, u } = this.dimensions;
|
||||
const w = (this.dimensions as area_t).w || null;
|
||||
const d = w ? `${l}${u} x ${w}${u}` : `${l}${u}`;
|
||||
return `$${p} per ${d}`
|
||||
}
|
||||
export function productPriceFor(
|
||||
product: Product,
|
||||
dimensions: dimensions_t,
|
||||
damage: number = 0
|
||||
): number {
|
||||
if (Number.isNaN(damage)) damage = 0;
|
||||
const dim = matchDimensions(dimensions, product.dimensions);
|
||||
return (
|
||||
(dim.w
|
||||
? (dimensionArea(dim) / dimensionArea(product.dimensions)) *
|
||||
product.pricePerUnit
|
||||
: (dim.l / product.dimensions.l) * product.pricePerUnit) *
|
||||
(1.0 - damage)
|
||||
);
|
||||
}
|
||||
|
||||
get attributesAsList() {
|
||||
return Object.entries(this.attributes).map(([key, value]) => {
|
||||
return { key, value }
|
||||
})
|
||||
}
|
||||
export function priceDisplay(price: number) {
|
||||
return price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
public removeAttribute(key: string) {
|
||||
this.attributes = Object.fromEntries(
|
||||
Object.entries(this.attributes).filter(
|
||||
([k, v]) => {
|
||||
k == key;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
export function pricePerUnitDisplay(product: Product) {
|
||||
const p = priceDisplay(product.pricePerUnit);
|
||||
const { l, u } = product.dimensions;
|
||||
const w = (product.dimensions as area_t).w || null;
|
||||
const d = w ? `${l}${u} x ${w}${u}` : `${l}${u}`;
|
||||
return `$${p} per ${d}`;
|
||||
}
|
||||
|
||||
get asObject(): ProductData {
|
||||
return {
|
||||
id: this.id,
|
||||
pricePerUnit: this.pricePerUnit,
|
||||
dimensions: this.dimensions,
|
||||
attributes: this.attributes,
|
||||
}
|
||||
}
|
||||
export function attributesAsList(attributes: ProductAttributes) {
|
||||
return Object.entries(attributes).map(([key, value]) => {
|
||||
return { key, value };
|
||||
});
|
||||
}
|
||||
|
||||
static fromObject({ id, pricePerUnit, dimensions, attributes }: ProductData) {
|
||||
return new Product(
|
||||
pricePerUnit,
|
||||
dimensions,
|
||||
attributes,
|
||||
id,
|
||||
)
|
||||
}
|
||||
}
|
||||
export function productLabel(
|
||||
product: Product,
|
||||
{ pricing }: { pricing: boolean } = { pricing: false }
|
||||
) {
|
||||
const n = product.attributes?.name || `Product ${product.id}`;
|
||||
const p = priceDisplay(product.pricePerUnit);
|
||||
const d = dimensionsDisplay(product.dimensions);
|
||||
if (!pricing) {
|
||||
return n;
|
||||
}
|
||||
return `${n} ($${p} per ${d})`;
|
||||
}
|
||||
|
||||
export function removeAttribute(
|
||||
attributes: { [key: string]: any },
|
||||
key: string
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(attributes).filter(([k, v]) => {
|
||||
k == key;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,8 @@ import { RenderOptions, render } from "@testing-library/react-native";
|
||||
import { PropsWithChildren, ReactElement } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { setupStore, RootState } from "@/app/store";
|
||||
import { Product, ProductData } from "@/lib/product";
|
||||
import { Product } from "@/lib/product";
|
||||
import { ProductData } from "./product";
|
||||
|
||||
export interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
|
||||
preloadedState?: Partial<RootState>;
|
||||
|
15
lib/util.ts
@ -0,0 +1,15 @@
|
||||
function waitForWindow(): Promise<Window> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
resolve(window);
|
||||
} else {
|
||||
const intervalId = setInterval(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
clearInterval(intervalId);
|
||||
resolve(window);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
56
package.json
@ -9,65 +9,63 @@
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web --offline",
|
||||
"test": "jest --watchAll",
|
||||
"lint": "expo lint"
|
||||
"lint": "expo lint",
|
||||
"apk:android": "eas build --platform android --local"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.7",
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"@expo/config-plugins": "~8.0.8",
|
||||
"@expo/prebuild-config": "~7.0.8",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||
"@react-native-async-storage/async-storage": "^1.24.0",
|
||||
"@react-native-community/slider": "^4.5.2",
|
||||
"@react-native/assets-registry": "^0.74.85",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
"@reduxjs/toolkit": "^2.2.6",
|
||||
"@testing-library/react-native": "^12.5.1",
|
||||
"@types/js-quantities": "^1.6.6",
|
||||
"@react-native/assets-registry": "^0.74.87",
|
||||
"@react-navigation/native": "^6.1.18",
|
||||
"@reduxjs/toolkit": "^2.2.7",
|
||||
"class-transformer": "^0.5.1",
|
||||
"convert": "^5.3.0",
|
||||
"enheter": "^1.0.27",
|
||||
"expo": "~51.0.17",
|
||||
"dayjs": "^1.11.12",
|
||||
"expo": "~51.0.28",
|
||||
"expo-asset": "^10.0.10",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-font": "~12.0.7",
|
||||
"expo-doctor": "^1.9.0",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-linear-gradient": "^13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-router": "~3.5.17",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.6",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"js-quantities": "^1.8.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.74.2",
|
||||
"react-native": "0.74.3",
|
||||
"react-native-element-dropdown": "^2.12.1",
|
||||
"react-native-flex-grid": "^1.0.4",
|
||||
"react-native-gesture-handler": "~2.16.2",
|
||||
"react-native-gesture-handler": "~2.18.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-select-dropdown": "^4.0.1",
|
||||
"react-native-svg": "^15.5.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-web": "~0.19.12",
|
||||
"react-redux": "^9.1.2",
|
||||
"redux-remember": "^5.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"safe-units": "^2.0.1",
|
||||
"uuid": "^10.0.0"
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-remember": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@testing-library/react-native": "^12.5.3",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.2.79",
|
||||
"@types/react-test-renderer": "^18.3.0",
|
||||
"babel-plugin-transform-es2015-destructuring": "^6.23.0",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"eas-cli": "^10.1.0",
|
||||
"enzyme-adapter-react-15": "^1.4.4",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"babel-preset-expo": "^11.0.14",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~51.0.3",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"ts-jest": "^29.1.5",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
"private": true
|
||||
|
6089
pnpm-lock.yaml
generated
54
svg/icon-carpet-roll.svg
Normal file
@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="142.37779mm"
|
||||
height="154.51445mm"
|
||||
viewBox="0 0 142.37779 154.51445"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
sodipodi:docname="icon-carpet-roll.svg"
|
||||
inkscape:export-filename="../assets/images/icons/carpet-roll-64.png"
|
||||
inkscape:export-xdpi="11.417511"
|
||||
inkscape:export-ydpi="11.417511"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#585858"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.79909512"
|
||||
inkscape:cx="427.98409"
|
||||
inkscape:cy="198.34935"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1008"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="681"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs2" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-16.165914,9.5264309)">
|
||||
<path
|
||||
id="path1159"
|
||||
style="color:#000000;fill:#000000;fill-rule:evenodd;stroke:#ffffff;stroke-width:5;stroke-linecap:square;stroke-linejoin:miter;stroke-dasharray:none;stroke-opacity:1"
|
||||
sodipodi:type="inkscape:offset"
|
||||
inkscape:radius="0.33110365"
|
||||
inkscape:original="M 85.214844 -6.9941406 C 70.013101 -6.494485 55.139955 -0.43163169 43.707031 9.5605469 C 30.013867 21.362005 20.944785 38.446198 19.085938 56.449219 C 17.358707 72.332698 21.014781 88.716233 29.230469 102.41016 C 39.520614 119.81061 56.638281 132.98252 76.03125 138.63672 C 82.65448 140.58173 89.540529 141.77025 96.458984 141.68555 C 115.61209 141.94239 134.76485 142.20153 153.91797 142.45703 C 154.55818 134.40231 155.19894 126.34763 155.83984 118.29297 C 135.18432 118.00696 114.52684 117.78015 93.873047 117.42578 C 80.647337 116.41488 67.916649 110.28207 58.582031 100.88867 C 52.070874 94.381035 47.193434 86.30892 44.728516 77.402344 C 42.589935 69.786839 42.32538 61.636547 44.011719 53.955078 C 46.999506 40.237159 56.336432 28.015139 68.902344 21.708984 C 76.179541 18.017237 84.56116 16.49232 92.609375 17.550781 C 105.47803 19.123751 117.29397 27.652918 122.69922 39.457031 C 124.2433 42.820885 125.26799 46.602101 125.57422 50.355469 C 126.08314 55.844903 125.04871 61.62513 122.51172 66.595703 C 118.69587 74.236827 111.48288 80.366569 102.99414 81.986328 C 98.380931 82.881417 93.437488 82.363391 89.1875 80.232422 C 83.668056 77.562868 79.16371 72.357272 78.208984 66.197266 C 77.642576 62.560594 78.60807 58.687652 80.996094 55.859375 C 80.898514 58.890704 81.095836 62.010499 82.367188 64.810547 C 84.05204 68.882567 87.592589 72.214241 91.904297 73.302734 C 95.777353 74.339207 99.867688 73.625621 103.61328 72.421875 C 106.30071 71.528147 108.63458 69.786802 110.41992 67.605469 C 114.58333 62.715455 116.25928 55.924687 115.11328 49.630859 C 113.74274 41.428115 107.89047 34.34961 100.46484 30.791016 C 92.86163 27.075438 83.717791 27.076984 75.933594 30.257812 C 64.775062 34.759192 56.518994 45.396663 54.443359 57.191406 C 52.491012 67.446627 54.957367 78.397692 60.941406 86.929688 C 68.583415 98.108042 81.45297 105.49998 94.955078 106.49414 C 106.54011 107.47506 118.38117 103.97767 127.69727 97.052734 C 140.03453 88.048478 148.27185 73.639108 149.67578 58.419922 C 151.00619 45.171814 147.4177 31.531028 139.85938 20.582031 C 130.27054 6.4471034 114.71504 -3.4768114 97.845703 -6.1816406 C 93.675003 -6.866132 89.439024 -7.1235453 85.214844 -6.9941406 z "
|
||||
d="M 85.203125,-7.3242188 C 69.920576,-6.8219072 54.978371,-0.73159347 43.488281,9.3105469 29.730781,21.167938 20.62376,38.325311 18.755859,56.416016 c -1.734796,15.95712 1.938409,32.407948 10.191407,46.164064 10.334605,17.47423 27.516469,30.69674 46.990234,36.375 6.645281,1.95148 13.56116,3.1467 20.517578,3.0625 19.153082,0.25684 38.305822,0.51598 57.458982,0.77148 a 0.33113676,0.33113676 0 0 0 0.33399,-0.30664 c 0.64021,-8.0547 1.28097,-16.1094 1.92187,-24.16406 a 0.33113676,0.33113676 0 0 0 -0.32617,-0.35742 c -20.65485,-0.286 -41.30981,-0.51288 -61.960938,-0.86719 h -0.0039 c -13.133341,-1.00893 -25.787797,-7.10439 -35.0625,-16.4375 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 52.343603,94.184995 47.495562,86.16238 45.046875,77.314453 a 0.33113676,0.33113676 0 0 0 0,-0.002 C 42.922997,69.749353 42.661672,61.651864 44.335938,54.025391 47.301902,40.407665 56.579386,28.264581 69.050781,22.005859 a 0.33113676,0.33113676 0 0 0 0.002,-0.002 c 7.218526,-3.661983 15.535683,-5.174225 23.513672,-4.125 a 0.33113676,0.33113676 0 0 0 0.002,0 c 12.749801,1.558443 24.475741,10.023864 29.830081,21.716797 1.528,3.328833 2.54287,7.075443 2.8457,10.787109 a 0.33113676,0.33113676 0 0 0 0,0.0039 c 0.50314,5.427095 -0.52104,11.148138 -3.02734,16.058593 a 0.33113676,0.33113676 0 0 0 -0.002,0.002 c -3.77296,7.555238 -10.91161,13.615485 -19.2832,15.21289 a 0.33113676,0.33113676 0 0 0 0,0.002 c -4.546338,0.882115 -9.418457,0.367934 -13.595702,-1.726562 a 0.33113676,0.33113676 0 0 0 -0.0039,-0.002 c -5.431125,-2.626837 -9.860013,-7.754966 -10.794922,-13.78711 -0.497265,-3.192735 0.282359,-6.536996 2.125,-9.185546 -0.0139,2.705038 0.257235,5.460018 1.404297,7.986328 1.724833,4.159718 5.336057,7.559506 9.757813,8.675781 3.964246,1.059122 8.116269,0.326271 11.890621,-0.886719 a 0.33113676,0.33113676 0 0 0 0.002,0 c 2.75294,-0.915515 5.13542,-2.694581 6.95508,-4.916016 l 0.004,-0.0059 c 4.22887,-4.970422 5.92559,-11.850563 4.76367,-18.238281 v -0.0039 c -1.39169,-8.320828 -7.31354,-15.475053 -14.83007,-19.078125 l -0.002,-0.002 C 92.910555,26.731743 83.676017,26.736335 75.808594,29.951172 64.535605,34.499437 56.212806,45.226465 54.117188,57.134766 c -1.968025,10.346436 0.517884,21.379932 6.552734,29.984375 7.700829,11.262849 20.653527,18.702169 34.257812,19.705079 11.668956,0.98802 23.584106,-2.53175 32.964846,-9.503908 l 0.002,-0.002 c 12.41469,-9.061773 20.69784,-23.548746 22.11133,-38.865234 v -0.002 c 1.33798,-13.328328 -2.27053,-27.040971 -9.87305,-38.054688 v -0.002 C 130.49093,6.1822132 114.86084,-3.7880615 97.898438,-6.5078125 93.705473,-7.195958 89.449462,-7.4542424 85.205078,-7.3242188 a 0.33113676,0.33113676 0 0 0 -0.002,0 z" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 6.5 KiB |