diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 00000000..4f6a17b4 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,8 @@ +NEXT_PUBLIC_APP_NAME='Sitecore Commerce Seller App' +NEXT_PUBLIC_OC_CLIENT_ID='4A9F0BAC-EC1D-4711-B01F-1A394F72F2B6' +NEXT_PUBLIC_OC_API_URL='https://sandboxapi.ordercloud.io' +NEXT_PUBLIC_OC_MARKETPLACE_ID='SitecoreCommerce' +NEXT_PUBLIC_OC_MARKETPLACE_NAME='Sitecore Commerce' +NEXT_PUBLIC_OC_USELIVEANALYTICSDATA='false' + + diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..26e3b0ef --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "extends": ["next", "prettier"], + "plugins": ["prettier"], + "rules": { + "prettier/prettier": ["error", {"endOfLine": "auto"}] + }, + "parserOptions": { + "sourceType": "module", + "ecmaVersion" : 2020 + } +} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..c802e4e6 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,12 @@ +on: + workflow_dispatch: + +name: release-please +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: google-github-actions/release-please-action@v3 + with: + release-type: node + package-name: release-please-action diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..2d60c3ea --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,22 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v3 + with: + node-version: 16.x + + - run: npx changelogithub + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3256cbc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/public/workbox-*.js +**/public/sw.js +**/public/service-worker.js + +# sitemap +robots.txt +sitemap*.xml + +# TS +*.tsbuildinfo +/.env +.env.vercelcommerce.local diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..00089c14 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +yarn run pre-commit diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..38466881 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# add some options to dissuade using npm + +engine-strict = true # errors if using engine not defined in package.json (yarn) +package-lock=false # don't generate package-lock.json \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..55009ae6 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "printWidth":120, + "tabWidth": 2, + "semi": false, + "singleQuote": false, + "trailingComma": "none", + "bracketSpacing": false, + "jsxBracketSameLine": false, + "arrowParens": "always", + "endOfLine":"auto" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..4a285788 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "vivaxy.vscode-conventional-commits", + "dsznajder.es7-react-js-snippets", + "mhutchie.git-graph", + "oderwat.indent-rainbow" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..12ffca5c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + // https://nextjs.org/docs/advanced-features/debugging#debugging-with-vs-code + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "yarn dev", + "serverReadyAction": { + "pattern": "started server on .+, url: (https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..949265a9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "workbench.editor.wrapTabs": true, + "workbench.editor.scrollToSwitchTabs": true, + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..81f3ac10 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Sitecore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..5ec0b445 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Sitecore Commerce Seller app +Welcome to Sitecore Commerce Seller App. This is a basic implementation of the Sitecore Commerce using the OrderCloud Javascript SDK. +You can use it as a starting point to discover, understand, and learn more about the Sitecore Commerce OrderCloud capabilities. + +This app showcase different marketplace scenarios and commerce strategies: B2B, B2C, B2B2C. + +## What is Sitecore Commerce OrderCloud? +---- +[OrderCloud](https://ordercloud.io/discover/platform-overview) is a B2B, B2C, B2X commerce and marketplace development platform, +OrderCloud delivers cloud-based, API-first, headless eCommerce architecture. Limitless customizations and endless freedom for growth to support your complete commerce strategy. + +## What is Sitecore Commerce Seller App? +---- +A **simple**, **powerful** and **flexible** Commerce Seller Application built on top of Sitecore [OrderCloud API](https://ordercloud.io/api-reference) and the [Javascript SDK](https://www.npmjs.com/package/ordercloud-javascript-sdk) built with: +* React +* Next.JS +* Typescript +* Chakra UI +* Toolings for linting, formatting, and conventions configured `eslint`, `prettier`, `husky`, `lint-staged`, `commitlint`, `commitizen`, and `standard-version` +* SEO optimization configured with `next-seo` and `next-sitemap`. you'll need to reconfigure or tinker with it to get it right according to your needs, but it's there if you need it. + +## What you can do with this app? +---- +* Create, read, update delete product catalogs and categories +* Create, read, update delete products with Extended propreties +* Create, read, update delete promotions +* Create, read, update, delete buyers +* Create, read, update, delete user groups and users +* Read, Update Orders + +## Requirement +Create an OrderCloud Marketplace instance (https://portal.ordercloud.io) + +## How do I get started? +Using the Deploy Button below, you'll deploy on Vercel the Next.js project as well as connect it to your Sitecore Commerce OrderCloud sandbox. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FSitecoreNA%2Fsitecore-commerce%2Ftree%2Fmain) + +## Working locally +---- +1. Pull the latest version from this github repository +2. Copy the `.env.local.example` file in the root directory to `.env.local` (ignored by default during your next Git commit): + +```bash +cp .env.local.example .env.local +``` + +Then set each variable on `.env.local`: +`NEXT_PUBLIC_APP_NAME` Application Name used on Page title. +`NEXT_PUBLIC_OC_CLIENT_ID` ClientID from portal.ordercloud.io +`NEXT_PUBLIC_OC_API_URL`='https://sandboxapi.ordercloud.io' Sandbox URL from portal.ordercloud.io +`NEXT_PUBLIC_OC_MARKETPLACE_ID` +`NEXT_PUBLIC_OC_MARKETPLACE_NAME` +`NEXT_PUBLIC_OC_USELIVEANALYTICSDATA`='false' + +Your `.env.local` file should look like this: + +```bash +NEXT_PUBLIC_APP_NAME='Sitecore Commerce Seller App' +NEXT_PUBLIC_OC_CLIENT_ID='****0BAC-****-4711-B01F-1A**4F7*****' +NEXT_PUBLIC_OC_API_URL='https://sandboxapi.ordercloud.io' +NEXT_PUBLIC_OC_MARKETPLACE_ID='SitecoreCommerce' +NEXT_PUBLIC_OC_MARKETPLACE_NAME='Sitecore Commerce' +NEXT_PUBLIC_OC_USELIVEANALYTICSDATA='false' +``` + +3. Run Next.js in development mode +```bash +yarn install +yarn dev +``` + +Your app should be up and running on [http://localhost:3000](http://localhost:3000)! +If it doesn't work, post on [GitHub issues](https://github.com/medkrimi/commercenext.js/discussions). + +### Seeding a new marketplace + +In some cases it may be useful to have your own marketplace. Maybe you need to create data for a specific workflow, or perhaps you want to insulate yourself from unwanted data changes right before a demo. To make this easy we've included a CLI command that will create a marketplace for you and pre-populate it with products from the play shop marketplace. + +```bash +npm run seed -- -u=YOUR_PORTAL_USERNAME -p=YOUR_PORTAL_PASSWORD -n=YOUR_MARKETPLACE_NAME +``` + +Next, find the admin client ID and set it as NEXT_PUBLIC_OC_CLIENT_ID in your .env file. + +Finally log in as `initialadminuser` with the password `Testingsetup123!` + +### Deploy on Vercel +To deploy your local project to Vercel, push it to public GitHub/GitLab/Bitbucket repository then [import to Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example). + +## Not implemented / on the Roadmap +* Create, read, update, delete sellers and suppliers +* Create, read, update, delete addresses +* .... + +## References +- [OrderCloud Javascript SDK](https://www.npmjs.com/package/ordercloud-javascript-sdk) +- [OrderCloud API Reference](https://ordercloud.io/api-reference) +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [Chakra UI](https://chakra-ui.com) +- [TypeScript](https://www.typescriptlang.org) diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next-seo.config.js b/next-seo.config.js new file mode 100644 index 00000000..81fcc6ee --- /dev/null +++ b/next-seo.config.js @@ -0,0 +1,26 @@ +/** @type {import('next-seo').DefaultSeoProps} */ +const defaultSEOConfig = { + title: "", + titleTemplate: "", + defaultTitle: "", + description: "Next.js + chakra-ui + TypeScript", + canonical: "", + openGraph: { + url: "", + title: "", + description: "", + images: [ + { + url: "", + alt: "", + }, + ], + site_name: "", + }, + twitter: { + handle: "", + cardType: "", + }, +}; + +export default defaultSEOConfig; diff --git a/next-sitemap.config.js b/next-sitemap.config.js new file mode 100644 index 00000000..754803e2 --- /dev/null +++ b/next-sitemap.config.js @@ -0,0 +1,7 @@ +/** @type {import('next-sitemap').IConfig} */ +const NextSitemapConfig = { + siteUrl: "https://www.sitecore.com", + generateRobotsTxt: true, +}; + +module.exports = NextSitemapConfig; diff --git a/next.config.js b/next.config.js new file mode 100644 index 00000000..d2a0dde0 --- /dev/null +++ b/next.config.js @@ -0,0 +1,30 @@ +/** @type {import('next').NextConfig} */ + +const withNextra = require("nextra")({ + theme: "nextra-theme-docs", + themeConfig: "./theme.config.tsx" +}) + +module.exports = withNextra({ + devIndicators: { + autoPrerender: true + }, + pwa: { + disable: + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "preview" || + process.env.NODE_ENV === "production", + // delete two lines above to enable PWA in production deployment + // add your own icons to public/manifest.json + // to re-generate manifest.json, you can visit https://tomitm.github.io/appmanifest/ + dest: "public", + register: true + }, + swcMinify: true, + reactStrictMode: true, + eslint: { + dirs: ["src"] + }, + // !! WARN !! + ignoreBuildErrors: true +}) diff --git a/package.json b/package.json new file mode 100644 index 00000000..741f963d --- /dev/null +++ b/package.json @@ -0,0 +1,80 @@ +{ + "name": "ocsellerapp", + "version": "0.1.0", + "private": true, + "author": "ghaeger", + "scripts": { + "dev": "next dev", + "build": "next build", + "postbuild": "next-sitemap --config next-sitemap.config.js", + "start": "next start", + "lint": "next lint", + "lint:fix": "eslint src --fix && yarn format", + "type-check": "tsc --noEmit", + "format": "prettier --write src", + "up": "yarn upgrade-interactive", + "up-latest": "yarn up --latest", + "release": "cross-env HUSKY=0 standard-version", + "push-release": "git push --follow-tags origin main", + "pre-commit": "yarn lint && yarn tsc --noEmit && yarn build", + "seed": "seeding seed playshop-marketplace.yml" + }, + "engines": { + "npm": "Please use yarn", + "yarn": ">=1.0.0" + }, + "dependencies": { + "@chakra-ui/icons": "^2.0.4", + "@chakra-ui/react": "^2.3.6", + "@emotion/react": "^11.9.3", + "@emotion/styled": "^11.10.5", + "@minoru/react-dnd-treeview": "^3.4.1", + "@react-querybuilder/chakra": "^5.4.0", + "@react-querybuilder/dnd": "^5.4.0", + "@reduxjs/toolkit": "^1.8.3", + "apexcharts": "^3.36.0", + "axios": "^1.2.0", + "formik": "^2.2.9", + "formik-chakra-ui": "^1.6.1", + "framer-motion": "^8.1.7", + "lodash": "^4.17.21", + "next": "12.2.0", + "next-seo": "^5.4.0", + "nextjs-breadcrumbs": "^1.1.9", + "nextra": "^2.2.14", + "nextra-theme-docs": "^2.2.14", + "ordercloud-javascript-sdk": "^5.0.0-beta.2", + "react": "^18.2.0", + "react-apexcharts": "^1.4.0", + "react-datepicker": "^4.8.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-dom": "^18.2.0", + "react-icons": "^4.4.0", + "react-querybuilder": "^5.4.0", + "react-table": "^7.8.0", + "universal-cookie": "^4.0.4", + "yup": "^0.32.11" + }, + "devDependencies": { + "@ordercloud/seeding": "^1.0.31", + "@types/date-fns": "^2.6.0", + "@types/node": "^18.11.9", + "@types/react": "^18.0.15", + "@types/react-datepicker": "^4.8.0", + "@types/react-sortable-tree": "^0.3.16", + "@types/react-table": "^7.7.12", + "cross-env": "^7.0.3", + "eslint": "^8.23.0", + "eslint-config-next": "^12.2.0", + "eslint-config-prettier": "^8.5.0", + "eslint-config-sznm": "^1.0.0", + "eslint-plugin-prettier": "^4.2.1", + "husky": "^8.0.1", + "next-sitemap": "^3.1.7", + "pre-commit": "^1.2.2", + "prettier": "^2.7.1", + "standard-version": "^9.5.0", + "typescript": "^4.7.4" + } +} diff --git a/playshop-marketplace.yml b/playshop-marketplace.yml new file mode 100644 index 00000000..994290d5 --- /dev/null +++ b/playshop-marketplace.yml @@ -0,0 +1,5327 @@ +Objects: + SecurityProfiles: + - ID: Middleware + Name: Middleware + Roles: + - FullAccess + - ID: BuyerUser + Name: Buyer User + Roles: + - MeAddressAdmin + - MeAdmin + - MeCreditCardAdmin + - MeXpAdmin + - PasswordReset + - Shopper + - ID: BuyerManager + Name: Buyer Manager + Roles: + - BuyerAdmin + - BuyerUserAdmin + - CatalogAdmin + - CategoryAdmin + - UserGroupAdmin + xp: { + IsPermission: true + } + - ID: MeManager + Name: Me Manager + Roles: + - MeAdmin + - MeXpAdmin + xp: { + IsPermission: true + } + - ID: OrderManager + Name: Order Manager + Roles: + - OrderAdmin + xp: { + IsPermission: true + } + - ID: ReportViewer + Name: Report Viewer + Roles: + - ProductAdmin + - OrderAdmin + - ID: ProductManager + Name: Product Manager + Roles: + - ProductAdmin + - PromotionAdmin + - PriceScheduleAdmin + xp: { + IsPermission: true + } + - ID: SettingsManager + Name: Settings Manager + Roles: + - ProductFacetAdmin + - AdminUserAdmin + - AdminUserGroupAdmin + - AdminAddressAdmin + xp: { + IsPermission: true + } + - ID: SupplierManager + Name: Supplier Manager + Roles: + - SupplierAdmin + - SupplierAddressAdmin + - SupplierUserAdmin + - SupplierUserGroupAdmin + xp: { + IsPermission: true + } + ImpersonationConfigs: + - ID: buyer1 + BuyerID: buyer1 + SecurityProfileID: BuyerUser + ClientID: BUYER_CLIENT_ID + OpenIdConnects: + - ID: Auth0Connection + OrderCloudApiClientID: BUYER_CLIENT_ID + ConnectClientID: ZpEPH2WiXPDSu3jYG9wUL9e8Kp4hchGb + ConnectClientSecret: L4W-kO5HqiEsaOm5LwvvXhlKtXttR3Sjvg3O1-hw7uwslAIhkjW0ZykuVqZz3__l + AppStartUrl: REPLACE_WITH_HOSTED_BUYER_URL{2}?oidcToken={0}&idpToken={1} + AuthorizationEndpoint: https://playdemo.us.auth0.com/authorize + TokenEndpoint: https://playdemo.us.auth0.com/oauth/token + UrlEncoded: true + IntegrationEventID: SingleSignOn + - ID: Auth0ConnectionLocal + OrderCloudApiClientID: BUYER_CLIENT_ID + ConnectClientID: ZpEPH2WiXPDSu3jYG9wUL9e8Kp4hchGb + ConnectClientSecret: L4W-kO5HqiEsaOm5LwvvXhlKtXttR3Sjvg3O1-hw7uwslAIhkjW0ZykuVqZz3__l + AppStartUrl: http://localhost:3000{2}?oidcToken={0}&idpToken={1} + AuthorizationEndpoint: https://playdemo.us.auth0.com/authorize + TokenEndpoint: https://playdemo.us.auth0.com/oauth/token + UrlEncoded: true + IntegrationEventID: SingleSignOn + AdminUsers: + - ID: middleware-user + Username: middleware-user + Password: null + FirstName: Middleware + LastName: Integrations + Email: fake@email.com + Phone: null + Active: true + - ID: initial-admin-user + Username: InitialAdminUser + Password: Testingsetup123! + FirstName: Initial + LastName: Admin + Email: fake@email.com + Phone: null + Active: true + AdminUserGroups: + - ID: BuyerManager + Name: Buyer Manager + Description: Associated with the BuyerManager security profile + - ID: MeManager + Name: Me Manager + Description: Associated with the MeManager security profile + - ID: OrderManager + Name: Order Manager + Description: Associated with the OrderManager security profile + - ID: ProductManager + Name: Product Manager + Description: Associated with the ProductManager security profile + - ID: ReportViewer + Name: Report Viewer + Description: Associated with the ReportViewer security profile + - ID: SupplierManager + Name: Supplier Manager + Description: Associated with the SupplierManager security profile + - ID: SettingsManager + Name: Settings Manager + Description: Associated with the SettingsManager security profile + AdminAddresses: [] + MessageSenders: [] + ApiClients: + - ID: MIDDLEWARE_CLIENT_ID + ClientSecret: + AccessTokenDuration: 600 + Active: true + AppName: Middleware + RefreshTokenDuration: 5760 + DefaultContextUserName: middleware-user + AllowSeller: true + - ID: ADMIN_CLIENT_ID + AccessTokenDuration: 600 + Active: true + AppName: Admin Application + RefreshTokenDuration: 5760 + DefaultContextUserName: null + xp: null + AllowAnySupplier: true + AllowSeller: true + - ID: BUYER_CLIENT_ID + AccessTokenDuration: 600 + Active: true + AppName: Buyer Application + RefreshTokenDuration: 5760 + DefaultContextUserName: anonymous-shopper + AllowAnyBuyer: true + Incrementors: [] + Webhooks: [] + IntegrationEvents: + - ElevatedRoles: + - FullAccess + ID: OrderCheckout + ConfigData: null + EventType: OrderCheckout + CustomImplementationUrl: REPLACE_WITH_HOSTED_BUYER_URL/api/checkout + Name: Order Checkout + HashKey: + - ElevatedRoles: + - BuyerUserAdmin + - UserGroupAdmin + ID: SingleSignOn + ConfigData: null + EventType: OpenIDConnect + CustomImplementationUrl: REPLACE_WITH_HOSTED_BUYER_URL/api/openid-connect + Name: Single Sign On + HashKey: + XpIndices: [] + Buyers: + - ID: buyer1 + Name: Buyer 1 + DefaultCatalogID: PlayShopPublic + Active: true + Users: + - ID: anonymous-shopper + Username: anonymous-shopper + Password: null + FirstName: anonymous + LastName: shopper + Email: fake@email.com + Phone: null + TermsAccepted: null + Active: true + AvailableRoles: + - MeAddressAdmin + - MeAdmin + - MeCreditCardAdmin + - MeXpAdmin + - PasswordReset + - Shopper + BuyerID: buyer1 + UserGroups: [] + Addresses: [] + CostCenters: [] + CreditCards: [] + SpendingAccounts: [] + ApprovalRules: [] + Catalogs: + - ID: PlayShopPublic + OwnerID: + Name: 'Play Shop Public Catalog ' + Description: '' + Active: true + Categories: + - ID: PSC0 + Name: Clothing + Description: null + ListOrder: 31 + Active: true + ParentID: null + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSA0 + Name: Activities + Description: null + ListOrder: 36 + Active: true + ParentID: null + ChildCount: 7 + xp: null + CatalogID: PlayShopPublic + - ID: PSSM0 + Name: Subscriptions and Memberships + Description: null + ListOrder: 39 + Active: true + ParentID: null + ChildCount: 5 + xp: null + CatalogID: PlayShopPublic + - ID: PSEW0 + Name: Electronics and Wearables + Description: null + ListOrder: 40 + Active: true + ParentID: null + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSEWH + Name: Health and Wellness + Description: null + ListOrder: 31 + Active: true + ParentID: PSEW0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSEWA + Name: Activity Trackers + Description: null + ListOrder: 31 + Active: true + ParentID: PSEW0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAH + Name: Hiking + Description: null + ListOrder: 31 + Active: true + ParentID: PSA0 + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSAY + Name: Yoga + Description: null + ListOrder: 34 + Active: true + ParentID: PSA0 + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSAF + Name: Fitness + Description: null + ListOrder: 35 + Active: true + ParentID: PSA0 + ChildCount: 5 + xp: null + CatalogID: PlayShopPublic + - ID: PSSMY + Name: Yoga + Description: null + ListOrder: 36 + Active: true + ParentID: PSSM0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSEWS + Name: Specialty + Description: null + ListOrder: 37 + Active: true + ParentID: PSEW0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAR + Name: Running + Description: null + ListOrder: 37 + Active: true + ParentID: PSA0 + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSSMSS + Name: Ski and snowboarding + Description: null + ListOrder: 38 + Active: true + ParentID: PSSM0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSSMG + Name: Golf + Description: null + ListOrder: 38 + Active: true + ParentID: PSSM0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAS + Name: Ski and snowboarding + Description: null + ListOrder: 44 + Active: true + ParentID: PSA0 + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSAC + Name: Cycling + Description: null + ListOrder: 45 + Active: true + ParentID: PSA0 + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSAG + Name: Golf + Description: null + ListOrder: 45 + Active: true + ParentID: PSA0 + ChildCount: 5 + xp: null + CatalogID: PlayShopPublic + - ID: PSSMC + Name: Cycling + Description: null + ListOrder: 45 + Active: true + ParentID: PSSM0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSSMF + Name: Fitness + Description: null + ListOrder: 47 + Active: true + ParentID: PSSM0 + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCW + Name: Women + Description: null + ListOrder: 47 + Active: true + ParentID: PSC0 + ChildCount: 5 + xp: null + CatalogID: PlayShopPublic + - ID: PSCM + Name: Men + Description: null + ListOrder: 47 + Active: true + ParentID: PSC0 + ChildCount: 5 + xp: null + CatalogID: PlayShopPublic + - ID: PSARE + Name: Electronics + Description: null + ListOrder: 28 + Active: true + ParentID: PSAR + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGA + Name: Accessories + Description: null + ListOrder: 29 + Active: true + ParentID: PSAG + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGE + Name: Electronics + Description: null + ListOrder: 29 + Active: true + ParentID: PSAG + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFC + Name: Clothing + Description: null + ListOrder: 29 + Active: true + ParentID: PSAF + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSAHC + Name: Clothing + Description: null + ListOrder: 30 + Active: true + ParentID: PSAH + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSCMB + Name: Bottoms + Description: null + ListOrder: 31 + Active: true + ParentID: PSCM + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCWT + Name: Tops + Description: null + ListOrder: 31 + Active: true + ParentID: PSCW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCWO + Name: Outerwear + Description: null + ListOrder: 32 + Active: true + ParentID: PSCW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCWA + Name: Accessories + Description: null + ListOrder: 32 + Active: true + ParentID: PSCW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFM + Name: Memberships and Lessons + Description: null + ListOrder: 33 + Active: true + ParentID: PSAF + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYA + Name: Accessories + Description: null + ListOrder: 33 + Active: true + ParentID: PSAY + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYC + Name: Clothing + Description: null + ListOrder: 33 + Active: true + ParentID: PSAY + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYM + Name: Memberships and Lessons + Description: null + ListOrder: 35 + Active: true + ParentID: PSAY + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFE + Name: Equipment + Description: null + ListOrder: 35 + Active: true + ParentID: PSAF + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSARC + Name: Clothing + Description: null + ListOrder: 35 + Active: true + ParentID: PSAR + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSASC + Name: Clothing + Description: null + ListOrder: 35 + Active: true + ParentID: PSAS + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSASE + Name: Equipment + Description: null + ListOrder: 37 + Active: true + ParentID: PSAS + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGC + Name: Clothing + Description: null + ListOrder: 40 + Active: true + ParentID: PSAG + ChildCount: 2 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFH + Name: Health Supplements + Description: null + ListOrder: 40 + Active: true + ParentID: PSAF + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCWF + Name: Footwear + Description: null + ListOrder: 40 + Active: true + ParentID: PSCW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCMT + Name: Tops + Description: null + ListOrder: 41 + Active: true + ParentID: PSCM + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSARA + Name: Accessories + Description: null + ListOrder: 41 + Active: true + ParentID: PSAR + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACE + Name: Equipment + Description: null + ListOrder: 42 + Active: true + ParentID: PSAC + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSACC + Name: Clothing and Accessories + Description: null + ListOrder: 42 + Active: true + ParentID: PSAC + ChildCount: 3 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGM + Name: Memberships and Lessons + Description: null + ListOrder: 42 + Active: true + ParentID: PSAG + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCMO + Name: Outerwear + Description: null + ListOrder: 43 + Active: true + ParentID: PSCM + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFA + Name: Accessories + Description: null + ListOrder: 43 + Active: true + ParentID: PSAF + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCMA + Name: Accessories + Description: null + ListOrder: 45 + Active: true + ParentID: PSCM + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGG + Name: Golf clubs and sets + Description: null + ListOrder: 45 + Active: true + ParentID: PSAG + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSASM + Name: Memberships and Lessons + Description: null + ListOrder: 45 + Active: true + ParentID: PSAS + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCMF + Name: Footwear + Description: null + ListOrder: 47 + Active: true + ParentID: PSCM + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAHE + Name: Equipment + Description: null + ListOrder: 47 + Active: true + ParentID: PSAH + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSCWB + Name: Bottoms + Description: null + ListOrder: 47 + Active: true + ParentID: PSCW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSARCW + Name: Women + Description: null + ListOrder: 28 + Active: true + ParentID: PSARC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGCW + Name: Women + Description: null + ListOrder: 32 + Active: true + ParentID: PSAGC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYAB + Name: Bags + Description: null + ListOrder: 33 + Active: true + ParentID: PSAYA + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACEM + Name: Maintenance + Description: null + ListOrder: 33 + Active: true + ParentID: PSACE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACES + Name: Safety + Description: null + ListOrder: 34 + Active: true + ParentID: PSACE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSASESB + Name: Snowboards + Description: null + ListOrder: 34 + Active: true + ParentID: PSASE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSASEP + Name: Parts and accessories + Description: null + ListOrder: 34 + Active: true + ParentID: PSASE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSASESSK + Name: Skis + Description: null + ListOrder: 34 + Active: true + ParentID: PSASE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACEE + Name: Electronics + Description: null + ListOrder: 35 + Active: true + ParentID: PSACE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAHCM + Name: Men + Description: null + ListOrder: 40 + Active: true + ParentID: PSAHC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSARCM + Name: Men + Description: null + ListOrder: 40 + Active: true + ParentID: PSARC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAGCM + Name: Men + Description: null + ListOrder: 40 + Active: true + ParentID: PSAGC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAHCW + Name: Women + Description: null + ListOrder: 41 + Active: true + ParentID: PSAHC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFCW + Name: Women + Description: null + ListOrder: 41 + Active: true + ParentID: PSAFC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFCM + Name: Men + Description: null + ListOrder: 42 + Active: true + ParentID: PSAFC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYAM + Name: Mats + Description: null + ListOrder: 42 + Active: true + ParentID: PSAYA + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACCM + Name: Men + Description: null + ListOrder: 42 + Active: true + ParentID: PSACC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACCW + Name: Women + Description: null + ListOrder: 47 + Active: true + ParentID: PSACC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSACCH + Name: Helmets + Description: null + ListOrder: 47 + Active: true + ParentID: PSACC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSASCW + Name: Women + Description: null + ListOrder: 47 + Active: true + ParentID: PSASC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSASCM + Name: Men + Description: null + ListOrder: 47 + Active: true + ParentID: PSASC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYCM + Name: Men + Description: null + ListOrder: 47 + Active: true + ParentID: PSAYC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFER + Name: Resistance Training + Description: null + ListOrder: 47 + Active: true + ParentID: PSAFE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFEM + Name: Machines + Description: null + ListOrder: 47 + Active: true + ParentID: PSAFE + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFEW + Name: Weights + Description: null + ListOrder: 47 + Active: true + ParentID: PSAFE + ChildCount: 4 + xp: null + CatalogID: PlayShopPublic + - ID: PSAYCW + Name: Women + Description: null + ListOrder: 48 + Active: true + ParentID: PSAYC + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFEWW + Name: Wearables + Description: null + ListOrder: 29 + Active: true + ParentID: PSAFEW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFEWK + Name: Kettlebells + Description: null + ListOrder: 30 + Active: true + ParentID: PSAFEW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFEWD + Name: Dumbells + Description: null + ListOrder: 40 + Active: true + ParentID: PSAFEW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + - ID: PSAFEWB + Name: Barbells + Description: null + ListOrder: 46 + Active: true + ParentID: PSAFEW + ChildCount: 0 + xp: null + CatalogID: PlayShopPublic + Suppliers: [] + Products: + - OwnerID: + DefaultPriceScheduleID: PSPOTG10CSWSB + AutoForward: false + ID: PSPOTG10CSWSB + Name: 10-Club Set With a Stand Bag + Description: >- + The complete golf club set is the perfect solution for beginners and + casual players, with everything you need to start playing right away. + + + The set is comprised of a lightweight, five compartment stand bag and 10 + golf clubs: driver, 3 wood, 5 hybrids (replaces the 5 iron), 6, 7, 8, + and 9 irons, pitching wedge, sand wedge, and putter. It is available in + five models: standard right hand, standard left hand, extended length, + graphite, and extended length graphite. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: OTG + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAGG + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/clubs-with-bag-1-product?v=378c7541 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/clubs-with-bag-2-product?v=6b7923fc + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/clubs-with-bag-3-product?v=158fbae6' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOTG10CSWSB/otg-10-club-set-with-a-stand-bag + - OwnerID: + DefaultPriceScheduleID: PSPCCBB + AutoForward: false + ID: PSPCCBB + Name: Bike Bell + Description: >- + In order to ensure your safety, we developed this bell. Aluminum dome + produces a powerful, clear sound. Only compatible with 22.2 mm + handlebars (one of the most common). Quick and easy assembly and + disassembly with a screwdriver. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-bell-product?v=294eec3d + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCBB/centercycle-bike-bell + - OwnerID: + DefaultPriceScheduleID: PSPSBC + AutoForward: false + ID: PSPSBC + Name: Bike Cover + Description: >- + The Striva Bike cover is the ultimate protection for your bicycle + against all the elements. + + + Whether you store your bicycle inside a garage or in the yard, you want + it to stay dry, without dust or scratches and protected against sunlight + - that's what this high-quality 190T Nylon waterproof cover will do + for you.
+ + + -190T Nylon with PU coating, extremely durable + + -Security eyelets at the front, enabling the cover to be locked securely + through the bikes front wheel, deterring theft (lock not included). + + -Storm strap at the middle, which keeps the cover in place on windy + days. + + -Elastic hems at the front middle and rear to allow for a nice snug fit + under both wheels. + + -High-quality drawstring pouch for easy and convenient storage. + + -Reflective loops to allow for easy location at night. + + + Dimensions + + Non-stretched size : L 200cm H 100cm W 80cmNote: Please measure your + bike prior to purchase to ensure your cover fits + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-cover-product?v=1a005210 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-cover-2-product?v=dd7b8bb0 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSBC/striva-bike-cover + - OwnerID: + DefaultPriceScheduleID: PSPSBGPS + AutoForward: false + ID: PSPSBGPS + Name: Bike GPS + Description: >- + Striva is a GPS bike computer that helps cyclists to train, compete and + navigate. The product consists of two parts: Striva Bike Mount and + Striva App. The Striva Bike Mount is a device which allows the user to + track his performance on the field. It has its own battery, so it can be + used even when there is no internet connection or the phone is not + nearby. The data collected by Striva are sent to the mobile app to allow + users to track and analyze their performance. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWS + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-gps-product?v=39d10443 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-gps-2-product?v=7395d8c5 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSBGPS/striva-bike-gps + - OwnerID: + DefaultPriceScheduleID: PSPCCBHB + AutoForward: false + ID: PSPCCBHB + Name: Bike Handlebar Bag + Description: >- + With this handlebar bag, you need not worry about leaving your things + behind while riding. The bag has a capacity of 2.5 L. It can hold a + pump, tools and other personal items. It can easily be attached to many + types of handlebars using rip-tabs. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-handlebar-bag-product?v=e7369e5e + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-handlebar-bag-white-background-product?v=f694c62c + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/bike-handlebar-bag-2-product?v=8218571a' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCBHB/centercycle-bike-handlebar-bag + - OwnerID: + DefaultPriceScheduleID: PSPOBITPRK + AutoForward: false + ID: PSPOBITPRK + Name: Bike Inner Tire Patch Repair Kit + Description: >- + When you get a flat tyre, it can be an extremely frustrating situation. + A puncture in your tube can leave you stranded and force you to waste + valuable time fixing the problem so that you can continue with your + ride. Our patch kit is small enough to fit into a jersey pocket, or even + a saddle bag, so that it's always available when you need it most. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Outrace + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/rapair-kit-product?v=f88b01a2 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOBITPRK/outrace-bike-inner-tire-patch-repair-kit + - OwnerID: + DefaultPriceScheduleID: PSPOBLS + AutoForward: false + ID: PSPOBLS + Name: Bike Light Set + Description: >- + The USB rechargeable bicycle lights are one of the most popular and + convenient ways to illuminate your path in low visibility conditions. + These bike lights provide you with safety while riding at night or in + the dark. The LED light is extremely bright, so it can be used for other + nighttime activities like walking, running, jogging and camping etc. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Outrace + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-lights-set-product?v=885c59f3 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-lights-product?v=01d88eac + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/bike-lights-2-product?v=ab10c54d' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOBLS/outrace-bike-light-set + - OwnerID: + DefaultPriceScheduleID: PSPCCBLSFRBPLED + AutoForward: false + ID: PSPCCBLSFRBPLED + Name: Bike Light Set Front/Rear Battery-Powered LED + Description: >- + This bike light set is made by a French company, and it has been + certified for road safety. It provides 25 lux of illumination (enough to + see at a gentle pace) from the front, and 15 lux from the rear. The rear + light has a range of visibility of 220°, which will allow vehicles + coming from any angle to see you. Front light has three modes: + High/low/flash (33 hours). Rear light has one mode: Constant RED (102 + hours) both are easy to install and use. They're waterproof and made of + durable materials that will last many years. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-lights-product?v=01d88eac + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-lights-2-product?v=ab10c54d + Currency: USD + ProductType: Standard + ProductUrl: >- + /shop/products/PSPCCBLSFRBPLED/centercycle-bike-light-set-frontrear-battery-powered-led + - OwnerID: + DefaultPriceScheduleID: PSPSBPH + AutoForward: false + ID: PSPSBPH + Name: Bike Phone Holder + Description: >- + Universal bicycle phone holder is compatible with 4.7-6.8 inches GPS + devices, such as Apple iPhone 13 Mini, iPhone 13, iPhone 13 Pro, iPhone + 13 Pro Max, iPhone 12 Mini, 12, 12 Pro, 12 Pro Max, iPhone 11, 11 Pro, + 11 Pro Max, Xs, Xs Max, XR, X, 8, 7, 7 Plus, Huawei P30 Pro 10 Pro P20 + P10, Samsung Galaxy S10 + S10 S9 + S9 S8 S8 + S7 S6, Note 9 8 7 6, LG, + HTC, Sony, Nokia, Nexus, other smart phones. Note: Please make sure that + the thickness of your device is less than 15 mm. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/phone-holder-product?v=0eda742b + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSBPH/striva-bike-phone-holder + - OwnerID: + DefaultPriceScheduleID: PSPSBABW + AutoForward: false + ID: PSPSBABW + Name: Black and Alloy Bicycle Wheel + Description: "
    \n\t
  • Raleigh Tru-build Wheels rear wheel;
  • \n\t
  • Suitable replacement for most 26-inch wheeled bikes;
  • \n\t
  • Made of alloy;
  • \n\t
  • Available in silver color;
  • \n\t
  • Measures 26-inch diameter by 1.75-inch width;
  • \n
\n" + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/wheel-product?v=ee28f5d1 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSBABW/striva-black-and-alloy-bicycle-wheel + - OwnerID: + DefaultPriceScheduleID: PSPSBBC + AutoForward: false + ID: PSPSBBC + Name: Black Bicycle Cassette + Description: > +

Features
+ + - Color: Black.
+ + - Material: Steel.
+ + - Package Size: 7.87x7.87x2.75inch.
+ + - Number of teeth: 11-13-16-20-24-28-32-36-40T
+ + The silver surface of the bicycle flywheel has not changed color. The + silver black surface looks exquisite and gives you a luxurious + feeling.
+ +
+ + Package Including
+ + 1 * Bicycle cassette

+ QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/black-bicycle-cassette-product?v=97c83532 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSBBC/striva-black-bicycle-cassette + - OwnerID: + DefaultPriceScheduleID: PSPSCBS + AutoForward: false + ID: PSPSCBS + Name: Body Scale + Description: >- + Powered by 3x AAA batteries + + Supports kg and lb readouts + + Measurement range between 100g (3.52 oz) and 150kg (23.6 stone) + + Reinforced wipe-clean glass finish with floating display + + High-precision BIA measurement chip + + 30cm long, 30cm wide, 2.5cm thick weighing 1.7kg + + Smartphone and app required + + Naturally the smart scale is for anyone wanting to weigh themselves, + however there are plenty of other use cases: + + + Families wanting to track kids weight + + Athletes monitoring looking to monitor trends of muscle mass and + hydration + + Those looking for a tool to aid them in losing weight through monitoring + + Parcel measuring for items over 100g + + Weight loss groups/classes of up to 16 people + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Sydney Cummings + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWH + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smartscalelead-product?v=f3fa0813 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCBS/sydney-cummings-body-scale + - OwnerID: + DefaultPriceScheduleID: PSPSBL + AutoForward: false + ID: PSPSBL + Name: Break Levers + Description: >- + Striva Brake Levers are designed to be the ultimate performance upgrade + for your bike. With a simple, clean, and ergonomic design, the Striva + Brake Lever will help any rider get more out of their ride. They are + compatible with most standard brake levers on the market today and are + easy to install. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/break-lever-product?v=2ee9ada3 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSBL/striva-break-levers + - OwnerID: + DefaultPriceScheduleID: PSPRRTCB + AutoForward: false + ID: PSPRRTCB + Name: Camel Back + Description: >- + Specs + + Capacity: 300 cu. in., 5 L. + + Weight: 8 oz. + + Hydration capacity: 70 oz. + + Fits torso: 15"-21". + + + + Construction + + Made from lightweight and durable materials. + + Air Support™ light back panel is designed with body mapping + technology for ventilation and comfort. + + Lightweight, breathable ventilated harness helps keep you cool. + + Integrated tool organization . + + Adjustable sternum strap for a custom fit. + + BPA-, BPS- and BPF-free. + + + Additional Features + + Secure, zippered pocket to store your keys and phone. + + Stretch overflow pocket keeps an extra layer handy. + + Reflectivity for low light safety. + + Includes helmet carry and light loop. + + 70 oz. Crux reservoir with Quicklink™ disconnect. + + Tube trap management keeps your drinking tube secure and accessible + when you need it. + +
  • Elastic pump loop.
  • + +
  • Imported.
  • + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Run Right Through + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSARA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/camel-back-product?v=6ec69bb2 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPRRTCB/run-right-through-camel-back + - OwnerID: + DefaultPriceScheduleID: PSPCCCCBC + AutoForward: false + ID: PSPCCCCBC + Name: Carbon Cycling Bottle Cage + Description: >- + It securely holds the cycling water bottle thanks to a carbon-fiber + contruction. Lightweight (0.9 oz.) and very sturdy. Compatible with + bicycles with bottle cage screws. Very sturdy carbon-fiber contruction. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/carbon-cycling-bottle-cage-product?v=312458fb + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCCCBC/centercycle-carbon-cycling-bottle-cage + - OwnerID: + DefaultPriceScheduleID: PSPSCBP + AutoForward: false + ID: PSPSCBP + Name: Classic Bike Paddles + Description: >- + Specifications: + + Material: aluminum alloy + + Weight: 185g (one pedal) + + Size: 4.61 * 3.94 * 0.8 inches, Axle: 9/16 + + Pin: 16 pins each + + Construction: bicycle pedals + + Application: BMX, MTB, road bike, city bike, etc. + + Color: Black + + + Installation Tips: + + Caution during installation: Right and left pedal signs with + "L" and "R" on top of the shaft! + + 1. Distinguish the left and right pedals during installation. Left pedal + counterclockwise, otherwise clockwise rotation of the right pedal. + + 2. First, sight the mounting holes of the crank after tightening them + with your hands. Then use the re-tightening tools. + + 3. If the left and right pedals are installed incorrectly, or if you + mount them with tools + + when you are not aiming at the mounting holes, you can install it + temporarily, but after a few days it can easily fall or fall. + + + Delivery: 1 pair of bicycle pedals + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-paddles-product?v=c0fd12e4 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCBP/striva-classic-bike-paddles + - OwnerID: + DefaultPriceScheduleID: PSPSCHF + AutoForward: false + ID: PSPSCHF + Name: Classic Hardtail Frame + Description: >- + Feature: + + 1. The bike front fork frame is made of ultralight carbon fiber + material. + + 2. Has a good sense of use and long life. Sturdy and durable. + + 3. It has excellent hardness, no deformation, and corrosion resistance. + + 4. Professional manufacturing, can replace your old or broken bike + frame. + + 5. Comes with seat post clip, tube shaft, and tail hook. A wonderful + accessory for every mountain bike. + + + Specification: + + Item Type: Bike Front Fork Frame + + Material: Carbon Fiber + + Color: As Pictures Shown + + Weight: Approx. 3600g/127oz + + Taper top pipe size: Approx. 41.8(42mm)/1.7in(top), + 52mm/2in(down)Seat Tube Adaptation: Approx. 27.2mm/1.1in seat tube + + Bottom Bracket: PF30, 73mm/2.9in + + Rear Fork: 148x12mm/5.8x0.5in + + 29ER: Can be matched with 27.5, 29ER wheelset + + Frame distribution: seatpost clip, tube shaft, and + tailhook + + (has been installed on the bike frame) + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/frame-product?v=db4c59d2 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCHF/striva-classic-hardtail-frame + - OwnerID: + DefaultPriceScheduleID: PSPCCCBL + AutoForward: false + ID: PSPCCCBL + Name: Combination Bike Lock + Description: >- + This bike lock is a patented, simple and quick to use bike storage + system that secures the wheels, saddle and other components of your bike + during short stops. It is made from a high-strength polymer and can be + used with wider saddles. It weighs 1.3 pounds and comes in at 59.1 + inches long. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/combination-bike-lock-product?v=b355531d + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCCBL/centercycle-combination-bike-lock + - OwnerID: + DefaultPriceScheduleID: PSPCCCHVA + AutoForward: false + ID: PSPCCCHVA + Name: Connection Hose and Valve Adapters + Description: >- + The Connection Hose and Valve Adapters kit has all the adapters you need + to make your pump work with a wide variety of valves. The kit includes: + + - A universal adapter that fits Schrader, Presta, Dunlop, Woods and most + other types of valves + + - A tough vinyl hose that's long enough for many pumps + + - An extension hose for those hard-to-reach valves + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/connection-hose-and-valve-adapters-product?v=20d7e176 + Currency: USD + ProductType: Standard + ProductUrl: >- + /shop/products/PSPCCCHVA/centercycle-connection-hose-and-valve-adapters + - OwnerID: + DefaultPriceScheduleID: PSPSDP + AutoForward: false + ID: PSPSDP + Name: Dropper Post + Description: >- + Striva Dropper Seat Post is an innovative remote dropper seat post with + a maximum travel of 100mm. It allows you to adjust the seat height + effortlessly, by using a paddle that is attached to the handlebar. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/dropper-post-product?v=16c93dea + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/dropper-post-3-product?v=6dea4cdf + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSDP/striva-dropper-post + - OwnerID: + DefaultPriceScheduleID: PSPRRTEKS + AutoForward: false + ID: PSPRRTEKS + Name: Elastic Knee Support + Description: >- + Specifically designed with a seamless circular knit it provides + compressive support to the knee, helping to relieve symptoms of + arthritis, bursitis, and tendinitis. Made from thinner material than + neoprene, whilst still providing a high level of support this Ultimate + Compression Knee support is ideal for anyone who doesn’t want the + bulk or heat generation of neoprene. It is also perfect if you have an + allergy to neoprene or latex. (Contains natural rubber latex: may cause + an allergic reaction in some users) + + + The compressive four-way stretch of the material + offers support to the areas that need it the most, mimicking + the support and action planes of key ligaments and tendons to reinforce + them. The compression technology also stimulates blood flow, preventing + the build-up of lactic acid which reduces the risk of cramps and + stiffness whilst also working to improve recovery and rehabilitation. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Run Right Through + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSARA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/knee-band-product?v=680e7ee0 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPRRTEKS/run-right-through-elastic-knee-support + - OwnerID: + DefaultPriceScheduleID: PSPSEBS + AutoForward: false + ID: PSPSEBS + Name: Ergonomic Bike Seat + Description: "\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n
    Color‎Black
    Size‎One Size
    Material type‎Leather
    Batteries included?‎No
    Product Dimensions‎35.56 x 17.78 x 7.62 cm; 453.59 Grams
    \n" + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/ergonomic-bike-seat-product?v=5aba1ca3 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/ergonomic-bike-seat-2-product?v=e367b7a7 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/ergonomic-bike-seat-3-product?v=a60f3a41' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSEBS/striva-ergonomic-bike-seat + - OwnerID: + DefaultPriceScheduleID: PSPCCFP + AutoForward: false + ID: PSPCCFP + Name: Foot Pump + Description: >- + The new generation of BTP-1 Bike Pump is the perfect choice to inflate + tires on bicycles, hybrid bikes, city bikes or even baby strollers and + wheelchairs. The BTP-1 Bike Pump is equipped with a pressure indicator + in bars and PSI (needle pressure gauge). It's designed for mountain bike + tires with maximum pressure 8 bars / 116 PSI. Clip connection. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-foot-pump-product?v=cbcee1dc + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCFP/centercycle-foot-pump + - OwnerID: + DefaultPriceScheduleID: PSPCCFLEDBBL + AutoForward: false + ID: PSPCCFLEDBBL + Name: Front LED Battery Bike Light + Description: >- + We have designed a battery powered bike light to be seen from the front. + It has an elastic fastening system that is easy to use. In continuous + mode, it will last 32 hours and in flashing mode it will last 44 hours. + 2 CR2032 batteries are included. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/front-led-battery-bike-light-product?v=5f40a0d1 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCFLEDBBL/centercycle-front-led-battery-bike-light + - OwnerID: + DefaultPriceScheduleID: PSPSGS + AutoForward: false + ID: PSPSGS + Name: Gift Set + Description: > + Our perfect holiday gift set for your bike friends and relatives + contains: + + pump + + bottle + + locking chain + + 3 bike lights + + metal pedals + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/gift-set-product?v=4b1dce7a + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSGS/striva-gift-set + - OwnerID: + DefaultPriceScheduleID: PSPOTGGBR + AutoForward: false + ID: PSPOTGGBR + Name: Golf Ball Retriever + Description: >- + It is especially irritating when the ball gets stuck in a water hole or + sand trap. It is also difficult to see golf balls that are in the water. + You can waste a lot of time trying to chase the golf balls by hand. Not + every person has the luxury of hiring a personal caddy to chase their + runaway golf balls. + + Quality golf balls are not cheap; plus, a lot of people go through the + extra expensive of having them monogrammed for easy identification on + the course. Losing golf balls on the course is like throwing money away. + Since some golf courses may charge by the hour, you can also waste + valuable time searching for a lost golf ball. If you are tired of + bending over and wearing your back out, it is high time to pick this + item! + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: OTG + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAGA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/golf-ball-retriever-product?v=0aea9b50 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOTGGBR/otg-golf-ball-retriever + - OwnerID: + DefaultPriceScheduleID: PSPPSGCS + AutoForward: false + ID: PSPPSGCS + Name: Golf Clubs Set + Description: >- + The new "Pro Staff Golf Iron Clubs Set" is packed with distance and + forgiveness technology to help you hit more accurate shots more easily. + This set has you covered from tee to green with the confidence you need + to make every shot from the driver, fairway wood, and hybrid on through + the irons, wedges, and mallet-shaped putter. It all comes packed in a + convenient stand bag so you can carry it down the fairways with ease. + + + Features: + + The lightweight 460cc driver features a larger sweet spot, more + forgiveness, and a graphite shaft for longer shots off the tee + + Fairway wood with a stainless steel clubhead and graphite shaft promotes + faster swing speeds and longer shots + + Confidence-inspiring hybrid is a great alternative to long irons for + more confidence and versatility in the long game + + Irons and wedges with perimeter weighting and progressive sole widths + provide improved control + + Mallet style putter with alignment aids for more accuracy and stability + rolling putts + + Lightweight, durable stand bag features five pockets, an additional + cooler pocket, backpack-style straps for easy carrying, and a convenient + rain hood + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Pro Staff + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAGG + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/golf-set-1-product?v=3b5d1507 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/golf-set-2-product?v=87653ac6 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/golf-set-3-product?v=dde21e9a' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPPSGCS/pro-staff-golf-clubs-set + - OwnerID: + DefaultPriceScheduleID: PSPRRTGWB + AutoForward: false + ID: PSPRRTGWB + Name: Grip Water Bottles + Description: >- + Hydration on the Move - Ergonomic and designed to be easy to drink from + whilst you are running or moving. + + Non-toxic BPA free plastic - Food grade material with no nasties - also + dishwasher Safe for easy cleaning (top shelf only) and convenience. + + Ergonomic sports grip - Easy to hold when jogging, hiking, working out + in the gym, at home, or absolutely anywhere to stay hydrated. + + Compact and lightweight - Each bottle holds 350ml of liquid, is slimline + in design so easy to store and easy to carry. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Run Right Through + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSARA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/water-bottles-product?v=be45cf13 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/water-bottle-2-product?v=3a3c58cd + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPRRTGWB/run-right-through-grip-water-bottles + - OwnerID: + DefaultPriceScheduleID: PSPGG + AutoForward: false + ID: PSPGG + Name: Gym bag + Description: >- + It comes with 9 compartments: 4 internal compartments and 5 external + pockets. The main internal compartment has enough space to keep your + shorts, T-shirts, and other belongings, 3 other internal pockets: for + storing phone, wallet, keys; The 5 external pockets store shoes, + drinking glass, headset, other belongings. The fitness bag is easily put + into the gym locker and the luggage of the aircraft. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Gameday + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAFA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/gym-bag-product?v=e36117ea + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/gym-bag-2-product?v=c39a11da + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/gym-bag-3-product?v=e5195aa1' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPGG/gameday-gym-bag + - OwnerID: + DefaultPriceScheduleID: PSPCCHP + AutoForward: false + ID: PSPCCHP + Name: Hand Pump + Description: >- + A universal hand pump that works with the three most popular valves: + Schrader, Presta and Dunlop. With its simple design it's easy to use + anywhere and anytime. Super compact, making it easy to carry in your bag + so you can always inflate tires on the go. The ultra-lightweight pump + fits easily into any bicycle or sports equipment bag and comes with an + attachment clip allowing you to take it with you anywhere you go. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-hand-pump-product?v=4e0023e4 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCHP/centercycle-hand-pump + - OwnerID: + DefaultPriceScheduleID: PSPAMMWSSRCJ + AutoForward: false + ID: PSPAMMWSSRCJ + Name: Male Moisture-Wicking Short Sleeved Road Cycling Jersey + Description: >- + Our road cycling jersey is designed to make a great introduction to road + cycling in warm weather. The jersey has a single zip pocket so you can + carry items safely. Perspiration is wicked to the outside and the jersey + dries quickly thanks to the 100% recycled polyester thread. Relaxed + cut: suitable for all body types. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 2 + VariantCount: 6 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Alba + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSCMT + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-2-product?v=536dc7a1 + Currency: USD + ProductType: Standard + ProductUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey + - OwnerID: + DefaultPriceScheduleID: PSPCCMBDL + AutoForward: false + ID: PSPCCMBDL + Name: Mini Bike D-Lock + Description: >- + We have developed this bike lock that offers an excellent level of + security to keep your bike safe from thieves. This lock is lightweight, + easy to carry and lets you secure your bike to a fixed point in an urban + environment. A high-security lock with a rating of 8/10, it secures the + frame to a fixed object by using the 6-inch shackle. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/mini-bike-d-lock-product?v=5f40a0d1 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCMBDL/centercycle-mini-bike-d-lock + - OwnerID: + DefaultPriceScheduleID: PSPSCMEKGM + AutoForward: false + ID: PSPSCMEKGM + Name: Mobile EKG Monitor + Description: >- + The power is in your hands when it comes to your heart health thanks to + the Sydney Cummings Mobile EKG Monitor. This innovative device pairs + with just about any smartphone to give you almost instant EKG results. + + Personal digital EKG device + + + Using your fingertips to capture medical-grade EKG in 30 seconds + + Track EKG recordings and interpretations between doctor visits + + Track multiple risk factors (EKG, blood pressure, activity, weight) in a + single platform + + Create a personal heart profile with artificial intelligence technology + + Capture heart rate and symptoms such as irregular heartbeat, Atrial + fibrillation, and more + + Mobile EKG and algorithms with 98% sensitivity and 97% specificity for + instant detection of AF + + Single-lead rhythm strip comparable to Lead 1 of standard EKG machines, + and is the most clinically-validated mobile EKG solution available + + State and record symptoms while taking EKG; + + Store your most recent EKG recording and analysis + + Share results with your doctor + + AliveCor is HIPAA compliant + + 1 Year Warranty + + 3V CR2016 coin cell battery + + Basic app service included + + Phone not included + + Compatible with iOs iPhones: 5 through 12, plus iPod Touch 7th Gen, iPad + Air, Pro, and Mini; Andriod Devices: Samsung Galaxy S5-S10, Samsung + notebooks 5-10, LG Nexus 5, HTC One, Google Pixel and Pixel XL 1-5 + + Unlock comprehensive control over your heart health with a 30-day trial + to our Premium service which gives you access to: + + + Unlimited EKG cloud storage and history + + Monthly printed summary report of all the EKG’s taken in a 30 day + period + + Blood Pressure Monitoring + + Weight tracking and medication information + + Heart Health Journal that integrates with Google Fit or Apple Health + Apps + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Sydney Cummings + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWH + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/ekg-monitor-product?v=5298fe31 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/ekg-monitor-2-product?v=da5267d2 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCMEKGM/sydney-cummings-mobile-ekg-monitor + - OwnerID: + DefaultPriceScheduleID: PSPCCM + AutoForward: false + ID: PSPCCM + Name: Multitool + Description: >- + A compact, lightweight 11 function folding tool that can easily handle + most repairs. This tool features a chain breaker that allows you to + remove chains with ease and it also doubles as a spoke wrench. The Bike + Multitool also includes an adjustable wrench, screwdrivers, hex wrenches + and more! + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/multitool-product?v=d4ae8061 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCM/centercycle-multitool + - OwnerID: + DefaultPriceScheduleID: PSPSCN + AutoForward: false + ID: PSPSCN + Name: Nutridis + Description: >- + A machine, that is an automated dispenser for nutrient supplements. + Nutridis provides exactly the amount of supplement required thereby + eliminating the excess. It takes in multiple dietary supplements and + keeps them in the internal compartments. Basis the user, the machine + will dispense just the amount of solid and semi-solid supplements + required at a designated time. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Sydney Cummings + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWH + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/nutridis-product?v=173865b1 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/nutridis-2-product?v=b05d3b08 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCN/sydney-cummings-nutridis + - OwnerID: + DefaultPriceScheduleID: PSPSCP + AutoForward: false + ID: PSPSCP + Name: Pillowbot + Description: >- + The Sleep Robot has sensors that detect your breathing rate. Through + these measurements, the Pillowbot can adapt itself to your breathing and + gently slow it down. Quiet breathing has been proven to reduce stress + and calm your mind. + + + The Pillowbot can play soothing music, nature sounds, or white/pink + noise. These sounds help you relax and can enhance the Pillowbot’s + calming effect. + + + With the Pillowbot Mobile App, you can easily adjust the settings of + your Pillowbot to your personal preferences. In the app, you can change + your breathing program or choose your favorite soothing sounds and + music. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Sydney Cummings + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWH + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/pillow-product?v=9455bc35 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCP/sydney-cummings-pillowbot + - OwnerID: + DefaultPriceScheduleID: PSPCCPGS + AutoForward: false + ID: PSPCCPGS + Name: Protective Gear Set + Description: >- + The shells are made of ABS material with foam reinforcement but also + feature a flexible and full-coverage fabric construction with additional + ventilated mesh. An elasticized rip-tab system allows for easy sizing. + The elbows and knees have stretch sleeves for a perfect fit. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACC + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/protective-gear-set-product?v=b3bef02c + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCPGS/centercycle-protective-gear-set + - OwnerID: + DefaultPriceScheduleID: PSPCCR + AutoForward: false + ID: PSPCCR + Name: Ratchet kit + Description: >- + Features a fine tooth ratchet mechanism with reverse lever and thumb + wheel for quick rotation and control in tight spots and includes + hardened steel tools in 2, 2.5, 3, 4, 5, 6, and 8 mm hex, T10, T15, T25 + Torx® and #2 Phillips bits, CrMo steel chain tool, two engineering + grade polymer tire levers and magnetic bit, holder. * The chain tool is + compatible with single and multi-speed chains up to 12 speeds, NOT + including hollow pin chains. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/ratchet-kit-3-product?v=e899159b + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCR/centercycle-ratchet-kit + - OwnerID: + DefaultPriceScheduleID: PSPCCRBLEDBL + AutoForward: false + ID: PSPCCRBLEDBL + Name: Rear Battery LED Bike Light + Description: >- + We have designed a battery powered bike light to be seen from the back. + It has an elastic fastening system that is easy to use. In continuous + mode, it will last 32 hours and in flashing mode it will last 44 hours. + 2 CR2032 batteries are included. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACES + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/rear-battery-led-bike-light-product?v=0d1331b7 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCRBLEDBL/centercycle-rear-battery-led-bike-light + - OwnerID: + DefaultPriceScheduleID: PSPSRG + AutoForward: false + ID: PSPSRG + Name: Rear Gearshift + Description: "\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n
    Color‎Black
    Style‎Direct Mount Rear Derailleur
    Number of speeds‎7
    Usage‎Cycling, Mountain, Road, Youth
    Batteries included?‎No
    Item model number‎RDA070D
    Package Dimensions‎13.9 x 13.5 x 8.2 cm; 380 Grams
    " + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/rear-gearshift-product?v=e7c18fde + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSRG/striva-rear-gearshift + - OwnerID: + DefaultPriceScheduleID: PSPPRBSWPP + AutoForward: false + ID: PSPPRBSWPP + Name: Running Belt Sport Waist Pack-Pouch + Description: >- + The Running Belt Sport Waist Pack-Pouch is a simple, lightweight and + comfortable belt that can hold your phone, keys, money while running. + The belt is made of a stretchy material that allows it to hug the body + tightly. A velcro strap keeps the pouch in place. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Plus + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSARA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/running-belt-sport-waist-pack-pouch-1-product?v=47e6d962 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPPRBSWPP/plus-running-belt-sport-waist-pack-pouch + - OwnerID: + DefaultPriceScheduleID: PSPCCSB + AutoForward: false + ID: PSPCCSB + Name: Saddle Bag + Description: >- + The bike saddle bag is designed for transporting small personal items + and bike repair supplies when cycling. It is designed to be secured to + the top tube of a bicycle frame via Velcro straps, and features a + waterproof main compartment with drawstring closure, as well as three + additional pockets. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/saddle-bag-product?v=05847bc3 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCSB/centercycle-saddle-bag + - OwnerID: + DefaultPriceScheduleID: PSPCCSBPC + AutoForward: false + ID: PSPCCSBPC + Name: Single Bike Protective Cover + Description: >- + A practical, compact, plain bike cover, easy to put over your bike + (elastic bands along the hem). Compatible with all bikes (without a + child seat). Keeps your bike clean from dust and light rain. Made of + durable material. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/single-bike-protective-cover-product?v=07b579da + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCSBPC/centercycle-single-bike-protective-cover + - OwnerID: + DefaultPriceScheduleID: PSPSSAB + AutoForward: false + ID: PSPSSAB + Name: Smart Audio Band + Description: >- + Striva Smart audio band provides real-time audio coaching for running, + cycling, cardio boxing, bodyweight workouts. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWS + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-audio-band-product?v=29a9d6f2 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSSAB/striva-smart-audio-band + - OwnerID: + DefaultPriceScheduleID: PSPSSB + AutoForward: false + ID: PSPSSB + Name: Smart Band + Description: | + Metrics: + - Blood Oxygenation + - Body Temperature + - Heart Rate + - Sleep + - Activity (Pedometer and sport modes) + + Features: + - Bluetooth connectivity

    + - Efficient power usage (5-7 days of usage) + - Dedicated companion app (Android and iOS) + - IP67 splash proof capability + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-band-1-product?v=844936af + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-band-2-product?v=6abe8b29 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/smart-band-3-product?v=467983f2' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSSB/striva-smart-band + - OwnerID: + DefaultPriceScheduleID: PSPSSFT + AutoForward: false + ID: PSPSSFT + Name: Smart Fitness Tracker + Description: >- + Striva Smart Fitness Tracker is a wearable device that tracks your body + vitals, movement and workout intensity. The tracker syncs with the + Striva mobile app to provide users with real-time data on their health. + The smart technology in Striva automatically adjusts the intensity of + your workout based on your heart rate and makes sure you get the most + out of every session! + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/fitness-tracker-product?v=021dc6dd + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSSFT/striva-smart-fitness-tracker + - OwnerID: + DefaultPriceScheduleID: PSPSSS + AutoForward: false + ID: PSPSSS + Name: Smart Sleeve + Description: > + The smart sleeve digitally reads and records your bioindicators. You can + sync the information via blutooth to your iOS or android device. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Striva + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-sleeve-product?v=7482700a + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-sleeve-2-product?v=f0cf33c1 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/smart-sleeve-3-product?v=7f9005b8' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSSS/striva-smart-sleeve + - OwnerID: + DefaultPriceScheduleID: PSPOUS + AutoForward: false + ID: PSPOUS + Name: Smartwatch + Description: >- + Tough and durable + + Tested to the extreme, waterproof 100 m. The highest grade of materials: + sapphire glass and grade 5 titanium in selected models. + + + Built to last + + Battery life 25 hours with best GPS, up to 170 hours in Tour mode. + + + Passionate about sports + + Sport expertise and support for over 80 sports (running, cycling, + swimming, hiking, and many more). + + + Designed for adventure + + Barometric altitude, route navigation on the watch, and sport-specific + heatmaps on OverUnder app. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: OverUnder + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smartwatch-product?v=11095651 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOUS/overunder-smartwatch + - OwnerID: + DefaultPriceScheduleID: PSPGSB + AutoForward: false + ID: PSPGSB + Name: Sports Bottle + Description: >- + Made from the toughest and safest non-toxic BPA-free TRITAN plastic, lid + made of food-grade PP material. Contribute to the protection of the + environment and save money with the recyclable materials. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Gameday + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAFA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/water-bottle-product?v=096ba4ad + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPGSB/gameday-sports-bottle + - OwnerID: + DefaultPriceScheduleID: PSPRFSAW + AutoForward: false + ID: PSPRFSAW + Name: Strengthening Ab Wheel + Description: >- + An ab wheel makes it easy for you to target your arms, back, and core + comfortably with a large wheel width for extra stability and beveled + edges for smooth directional transitions. The wheel also has a raised, + non-slip handle texture that provides you with a superior grip so you + can focus on your body and movement. Each ab wheel comes with an + exercise guide to help you get started — or continue on — + your journey to feeling and being your best self. + + + When families come together to discover the joy of staying active. From + running errands to running intervals, the collection AIMs to propel you + forward with thoughtfully designed pieces that fit and feel great + — movement is the core of this performance line, with a 100% + satisfaction guarantee. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Robin Fitness + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAFE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/abb-wheel-1-product?v=9dbb093f + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/abb-wheel-2-product?v=43b7ce08 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/abb-wheel-3-product?v=c8c422a5' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPRFSAW/robin-fitness-strengthening-ab-wheel + - OwnerID: + DefaultPriceScheduleID: PSPOUSC + AutoForward: false + ID: PSPOUSC + Name: Studio Cycle + Description: >- + If you love a real studio ride, the new ICR50 makes it easy to take on a + virtually limitless variety of cycling workouts in the comfort of your + own home. Advanced ergonomics and precision design deliver performance + every bit as intense as your favorite class. + + + Bluetooth connects with leading training apps + + Precision resistance lever quickly changes intensity + + Protected, rear-mounted flywheel shields critical components + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: OverUnder + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAFEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/studio-cycle-product?v=a92478f6 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/studio-cycle-2-product?v=40fa6e56 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/studio-cycle-3-product?v=113cdfc5' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOUSC/overunder-studio-cycle + - OwnerID: + DefaultPriceScheduleID: PSPPSSGB + AutoForward: false + ID: PSPPSSGB + Name: Sunday Golf Bag + Description: >- + Carry/Sunday bags are the most compact of all golf bags. + + + They are structure-less and easy to carry. + + They can be folded up for easy storage, and on the course, their main + function is to carry the bare essentials. + + + There is a hookup for a golf towel, a single large pocket, and a ball + pocket, but that’s about it. + + + Their lightweight design make the "Pro Staff Sunday Golf Bag" great for + sneaking in a fast round in the morning before work or if you want to + bring your sticks on the road but are tight on storage. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Pro Staff + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAGA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/sunday-golf-bag-product?v=23b29a0c + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPPSSGB/pro-staff-sunday-golf-bag + - OwnerID: + DefaultPriceScheduleID: PSPCCTP + AutoForward: false + ID: PSPCCTP + Name: Travel Pump + Description: >- + This pump fills both large volume fat bike tires and higher pressure MTB + tires, as well as gravel bike tires with ease. Its technology uses both + strokes to compress air to deliver high pressure output and saves time + and effort. Integrated dust cap keeps pump head clean and thumb lock + lever ensures air-tight seal. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/telescopic-bike-pump-product?v=e3799b74 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/telescopic-bike-pump-2-product?v=93a28d03 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/telescopic-bike-pump-3-product?v=230e40ce' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCTP/centercycle-travel-pump + - OwnerID: + DefaultPriceScheduleID: PSPOUT + AutoForward: false + ID: PSPOUT + Name: Treadmill + Description: >- + Experience a natural, powerful run with the industry's most advanced + frame and deck combination. Our treadmill includes a folding design. + + + + 20 x 55 running surface + + 0.5 - 12.5 mph speed range + + 0 - 15% incline range + + Near-90 degree folding + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: OverUnder + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAFEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/treadmill-product?v=dd24f34f + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/treadmill-2-product?v=fd52c4b8 + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/treadmill-3-product?v=96a41f57 ' + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/treadmill-5-product?v=193558f6' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOUT/overunder-treadmill + - OwnerID: + DefaultPriceScheduleID: PSPCCT + AutoForward: false + ID: PSPCCT + Name: Tubyhead + Description: >- + CenterCycle Tubyhead’s transparent body allows you to see the + Presta valve core being removed, and after inflation, reinstalled with + no loss of pressure. With the valve core removed, larger air volume is + rapidly delivered by a standard floor pump to quickly seat and inflate + tubeless tires or other large volume tires. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/tubyhead-1-product?v=f79a46dd + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/tubyhead-2-product?v=32ecb2eb + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCT/centercycle-tubyhead + - OwnerID: + DefaultPriceScheduleID: PSPSCW + AutoForward: false + ID: PSPSCW + Name: Watch + Description: >- + A rugged, water-resistant wristwatch that includes features such as an + alarm, stopwatch, compass, heart rate monitor, tachymeter (rotating + bezel for calculating speed), thermometer, and tide indicator (for + divers). + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Sydney Cummings + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSEWA + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-watch-product?v=869a341f + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/smart-watch-2-product?v=27dd8ea1 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPSCW/sydney-cummings-watch + - OwnerID: + DefaultPriceScheduleID: PSPCCWSH + AutoForward: false + ID: PSPCCWSH + Name: Waterproof Smartphone Holder + Description: >- + The case provides waterproof protection for your smartphone while riding + your bike. It is easy to mount on either the stem or handlebar. The case + measures from 2 to 3.5 inches and the maximum phone measurements are + 5.5" x 2.7" x 0.3". + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/waterproof-smartphone-holder-product?v=df66ff4b + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCWSH/centercycle-waterproof-smartphone-holder + - OwnerID: + DefaultPriceScheduleID: PSPCCWBBC + AutoForward: false + ID: PSPCCWBBC + Name: Wire Bike Bottle Cage + Description: >- + This bike bottle cage is a water bottle holder designed to attach to the + bike frame. It has rubber parts that prevent slipping and can be easily + adjusted to fit different diameter of bottles. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/bike-bottle-cage-product?v=1a4424b0 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCWBBC/centercycle-wire-bike-bottle-cage + - OwnerID: + DefaultPriceScheduleID: PSPCCWC + AutoForward: false + ID: PSPCCWC + Name: Wireless Cyclometer + Description: >- + Our engineers have designed the cyclometer for cyclists looking for a + simple, reliable cyclometer that can measure their speed, distance, + time, temperature, and route. It mounts easily to your wheel hub, and + the magnet-free sensor is easy to install. There are three easy-to-use + buttons and a simple, intuitive user interface. Backlit figures and the + case is water-resistant, so it's easy to see, even in the dark. A sensor + can be attached to the back wheel to use it with a turbo trainer. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEE + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/wireless-cyclometer-product?v=ef0ea54e + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCWC/centercycle-wireless-cyclometer + - OwnerID: + DefaultPriceScheduleID: PSPCCW + AutoForward: false + ID: PSPCCW + Name: Work stand + Description: >- + This holder accepts bikes with quick-release fork or 12 x 100 mm, 15 x + 100 / 110 mm, 20 x 110 mm thru-axle forks. The holder height is + adjustable between 85 cm - 145 cm. It is foldable thanks to the three QR + clamps.The base is made of HD folding 6061-T6 aluminium tubes. The + maximum load is 18kg. With a weight of less than 5kg and a compact size + when folded it offers great function without taking up too much space. + An additinal tool tray and carry bag can be purchased. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: CenterCycle + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSACEM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/work-stand-product?v=10f37c88 + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/work-stand-2-product?v=b1ece29e + - ThumbnailUrl: '' + Url: ' https://ch.sitecoredemo.com/api/public/content/work-stand-3-product?v=3580a79f' + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPCCW/centercycle-work-stand + - OwnerID: + DefaultPriceScheduleID: PSPOYFM + AutoForward: false + ID: PSPOYFM + Name: Yoga and Fitness Mat + Description: >- + Thanks to its thickness of 0.6 cm, it is particularly gentle for the + joints. It offers maximum comfort for the knees, elbows, and hips. As + safe support, it is perfect for most yoga styles as well as aerobics, + Pilates, gymnastics, back, abdominal, gymnastics for pregnancy, + exercise, bodybuilding, and gymnastics for children. It is also ideal + for ballet schools and clinics. + + + The yoga mat is non-toxic, PVC-free, metal-free, non-irritating, and + heavy metals. The material is thermoplastic elastomer- TPE. This + material is environmental and user-friendly, hypoallergenic, and + skin-friendly. And there is still a natural oxidation benefit, can be + recycled, avoid pollution, good elasticity, strong resistance. Do not + worry about using our yoga mats. + QuantityMultiplier: 1 + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Active: true + SpecCount: 0 + VariantCount: 0 + ShipFromAddressID: null + Inventory: null + DefaultSupplierID: null + AllSuppliersCanSell: false + Returnable: false + xp: + Status: Draft + Brand: Outrace + UnitOfMeasure: + Qty: 1 + Unit: Per + CCID: PSAYAM + Images: + - ThumbnailUrl: '' + Url: >- + https://ch.sitecoredemo.com/api/public/content/fitness-mat-product?v=6e8d20d6 + Currency: USD + ProductType: Standard + ProductUrl: /shop/products/PSPOYFM/outrace-yoga-and-fitness-mat + Variants: + - ID: PSPAMMWSSRCJ-BK-L + Name: null + Description: null + Active: true + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Inventory: null + Specs: + - SpecID: PSPAMMWSSRCJ-Color + Name: Color + OptionID: PSPAMMWSSRCJ-Color-black + Value: black + PriceMarkupType: NoMarkup + PriceMarkup: null + - SpecID: PSPAMMWSSRCJ-Size + Name: Size + OptionID: PSPAMMWSSRCJ-Size-L + Value: L + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: + Images: + - Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-1-product?v=68cd7e0f + ThumbnailUrl: '' + SkuUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey/PSPAMMWSSRCJ-BK-L + ProductID: PSPAMMWSSRCJ + - ID: PSPAMMWSSRCJ-BK-M + Name: null + Description: null + Active: true + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Inventory: null + Specs: + - SpecID: PSPAMMWSSRCJ-Color + Name: Color + OptionID: PSPAMMWSSRCJ-Color-black + Value: black + PriceMarkupType: NoMarkup + PriceMarkup: null + - SpecID: PSPAMMWSSRCJ-Size + Name: Size + OptionID: PSPAMMWSSRCJ-Size-M + Value: M + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: + Images: + - Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-1-product?v=68cd7e0f + ThumbnailUrl: '' + SkuUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey/PSPAMMWSSRCJ-BK-M + ProductID: PSPAMMWSSRCJ + - ID: PSPAMMWSSRCJ-BK-S + Name: null + Description: null + Active: true + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Inventory: null + Specs: + - SpecID: PSPAMMWSSRCJ-Color + Name: Color + OptionID: PSPAMMWSSRCJ-Color-black + Value: black + PriceMarkupType: NoMarkup + PriceMarkup: null + - SpecID: PSPAMMWSSRCJ-Size + Name: Size + OptionID: PSPAMMWSSRCJ-Size-S + Value: S + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: + Images: + - Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-1-product?v=68cd7e0f + ThumbnailUrl: '' + SkuUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey/PSPAMMWSSRCJ-BK-S + ProductID: PSPAMMWSSRCJ + - ID: PSPAMMWSSRCJ-BL-L + Name: null + Description: null + Active: true + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Inventory: null + Specs: + - SpecID: PSPAMMWSSRCJ-Color + Name: Color + OptionID: PSPAMMWSSRCJ-Color-blue + Value: blue + PriceMarkupType: NoMarkup + PriceMarkup: null + - SpecID: PSPAMMWSSRCJ-Size + Name: Size + OptionID: PSPAMMWSSRCJ-Size-L + Value: L + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: + Images: + - Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-2-product?v=536dc7a1 + ThumbnailUrl: '' + SkuUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey/PSPAMMWSSRCJ-BL-L + ProductID: PSPAMMWSSRCJ + - ID: PSPAMMWSSRCJ-BL-M + Name: null + Description: null + Active: true + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Inventory: null + Specs: + - SpecID: PSPAMMWSSRCJ-Color + Name: Color + OptionID: PSPAMMWSSRCJ-Color-blue + Value: blue + PriceMarkupType: NoMarkup + PriceMarkup: null + - SpecID: PSPAMMWSSRCJ-Size + Name: Size + OptionID: PSPAMMWSSRCJ-Size-M + Value: M + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: + Images: + - Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-2-product?v=536dc7a1 + ThumbnailUrl: '' + SkuUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey/PSPAMMWSSRCJ-BL-M + ProductID: PSPAMMWSSRCJ + - ID: PSPAMMWSSRCJ-BL-S + Name: null + Description: null + Active: true + ShipWeight: null + ShipHeight: null + ShipWidth: null + ShipLength: null + Inventory: null + Specs: + - SpecID: PSPAMMWSSRCJ-Color + Name: Color + OptionID: PSPAMMWSSRCJ-Color-blue + Value: blue + PriceMarkupType: NoMarkup + PriceMarkup: null + - SpecID: PSPAMMWSSRCJ-Size + Name: Size + OptionID: PSPAMMWSSRCJ-Size-S + Value: S + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: + Images: + - Url: >- + https://ch.sitecoredemo.com/api/public/content/male-moisture-wicking-short-sleeved-road-cycling-jersey-2-product?v=536dc7a1 + ThumbnailUrl: '' + SkuUrl: >- + /shop/products/PSPAMMWSSRCJ/alba-male-moisture-wicking-short-sleeved-road-cycling-jersey/PSPAMMWSSRCJ-BL-S + ProductID: PSPAMMWSSRCJ + PriceSchedules: + - OwnerID: + ID: PSPOTG10CSWSB + Name: 10-Club Set With a Stand Bag + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 299.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCBB + Name: Bike Bell + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 5.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSBC + Name: Bike Cover + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 15.95 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSBGPS + Name: Bike GPS + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 256.95 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCBHB + Name: Bike Handlebar Bag + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 19.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOBITPRK + Name: Bike Inner Tire Patch Repair Kit + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 35 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOBLS + Name: Bike Light Set + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 25.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCBLSFRBPLED + Name: Bike Light Set Front/Rear Battery-Powered LED + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 24.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSBPH + Name: Bike Phone Holder + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 24.95 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSBABW + Name: Black and Alloy Bicycle Wheel + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 60 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSBBC + Name: Black Bicycle Cassette + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 50 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCBS + Name: Body Scale + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 250 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSBL + Name: Break Levers + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 89.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPRRTCB + Name: Camel Back + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 50 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCCCBC + Name: Carbon Cycling Bottle Cage + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 34.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCBP + Name: Classic Bike Paddles + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 50 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCHF + Name: Classic Hardtail Frame + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 250 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCCBL + Name: Combination Bike Lock + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 14.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCCHVA + Name: Connection Hose and Valve Adapters + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 6.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSDP + Name: Dropper Post + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 145.49 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPRRTEKS + Name: Elastic Knee Support + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 150 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSEBS + Name: Ergonomic Bike Seat + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 119.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCFP + Name: Foot Pump + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 24.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCFLEDBBL + Name: Front LED Battery Bike Light + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 6.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSGS + Name: Gift Set + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 60 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOTGGBR + Name: Golf Ball Retriever + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 20 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPPSGCS + Name: Golf Clubs Set + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 985 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPRRTGWB + Name: Grip Water Bottles + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 25 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPGG + Name: Gym bag + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 50 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCHP + Name: Hand Pump + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 6.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPAMMWSSRCJ + Name: Male Moisture-Wicking Short Sleeved Road Cycling Jersey + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 12.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCMBDL + Name: Mini Bike D-Lock + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 34.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCMEKGM + Name: Mobile EKG Monitor + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 50 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCM + Name: Multitool + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 25 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCN + Name: Nutridis + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 750 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCP + Name: Pillowbot + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 650 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCPGS + Name: Protective Gear Set + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 24.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCR + Name: Ratchet kit + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 52.95 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCRBLEDBL + Name: Rear Battery LED Bike Light + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 6.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSRG + Name: Rear Gearshift + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 119.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPPRBSWPP + Name: Running Belt Sport Waist Pack-Pouch + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 20 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCSB + Name: Saddle Bag + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 14.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCSBPC + Name: Single Bike Protective Cover + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 34.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSSAB + Name: Smart Audio Band + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 150 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSSB + Name: Smart Band + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 45 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSSFT + Name: Smart Fitness Tracker + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 19.95 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSSS + Name: Smart Sleeve + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 150 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOUS + Name: Smartwatch + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 100 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPGSB + Name: Sports Bottle + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 5 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPRFSAW + Name: Strengthening Ab Wheel + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 15 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOUSC + Name: Studio Cycle + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 2200 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPPSSGB + Name: Sunday Golf Bag + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 1000 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCTP + Name: Travel Pump + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 40 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOUT + Name: Treadmill + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 1920 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCT + Name: Tubyhead + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 45.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPSCW + Name: Watch + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 450 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCWSH + Name: Waterproof Smartphone Holder + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 29.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCWBBC + Name: Wire Bike Bottle Cage + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 6.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCWC + Name: Wireless Cyclometer + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 39.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPCCW + Name: Work stand + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 269.99 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + - OwnerID: + ID: PSPOYFM + Name: Yoga and Fitness Mat + ApplyTax: false + ApplyShipping: false + MinQuantity: 1 + MaxQuantity: null + UseCumulativeQuantity: false + RestrictedQuantity: false + PriceBreaks: + - Quantity: 1 + Price: 25 + SalePrice: null + Currency: null + SaleStart: null + SaleEnd: null + IsOnSale: false + xp: null + Specs: + - OwnerID: + ID: PSPAMMWSSRCJ-Color + ListOrder: 1 + Name: Color + DefaultValue: null + Required: true + AllowOpenText: false + DefaultOptionID: null + DefinesVariant: true + xp: null + OptionCount: 2 + Options: + - ID: PSPAMMWSSRCJ-Color-blue + Value: blue + ListOrder: 1 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + - ID: PSPAMMWSSRCJ-Color-black + Value: black + ListOrder: 2 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + - OwnerID: + ID: PSPAMMWSSRCJ-Size + ListOrder: 2 + Name: Size + DefaultValue: null + Required: true + AllowOpenText: false + DefaultOptionID: null + DefinesVariant: true + xp: null + OptionCount: 3 + Options: + - ID: PSPAMMWSSRCJ-Size-S + Value: S + ListOrder: 1 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + - ID: PSPAMMWSSRCJ-Size-M + Value: M + ListOrder: 2 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + - ID: PSPAMMWSSRCJ-Size-L + Value: L + ListOrder: 3 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + SpecOptions: + - ID: PSPAMMWSSRCJ-Color-blue + Value: blue + ListOrder: 1 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + SpecID: PSPAMMWSSRCJ-Color + - ID: PSPAMMWSSRCJ-Color-black + Value: black + ListOrder: 2 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + SpecID: PSPAMMWSSRCJ-Color + - ID: PSPAMMWSSRCJ-Size-S + Value: S + ListOrder: 1 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + SpecID: PSPAMMWSSRCJ-Size + - ID: PSPAMMWSSRCJ-Size-M + Value: M + ListOrder: 2 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + SpecID: PSPAMMWSSRCJ-Size + - ID: PSPAMMWSSRCJ-Size-L + Value: L + ListOrder: 3 + IsOpenText: false + PriceMarkupType: NoMarkup + PriceMarkup: null + xp: null + SpecID: PSPAMMWSSRCJ-Size + ProductFacets: [] + Promotions: [] +Assignments: + UserGroupAssignments: [] + SpendingAccountAssignments: [] + AddressAssignments: [] + CostCenterAssignments: [] + CreditCardAssignments: [] + CategoryAssignments: [] + CategoryProductAssignments: [] + SecurityProfileAssignments: + - SecurityProfileID: BuyerUser + BuyerID: buyer1 + SupplierID: null + UserID: null + UserGroupID: null + - SecurityProfileID: Middleware + BuyerID: null + SupplierID: null + UserID: middleware-user + UserGroupID: null + - SecurityProfileID: BuyerManager + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: BuyerManager + - SecurityProfileID: MeManager + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: MeManager + - SecurityProfileID: OrderManager + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: OrderManager + - SecurityProfileID: ProductManager + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: ProductManager + - SecurityProfileID: ReportViewer + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: ReportViewer + - SecurityProfileID: SupplierManager + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: SupplierManager + - SecurityProfileID: SettingsManager + BuyerID: null + SupplierID: null + UserID: null + UserGroupID: SettingsManager + AdminUserGroupAssignments: + - UserGroupID: BuyerManager + UserID: initial-admin-user + - UserGroupID: MeManager + UserID: initial-admin-user + - UserGroupID: OrderManager + UserID: initial-admin-user + - UserGroupID: ProductManager + UserID: initial-admin-user + - UserGroupID: ReportViewer + UserID: initial-admin-user + - UserGroupID: SupplierManager + UserID: initial-admin-user + - UserGroupID: SettingsManager + UserID: initial-admin-user + ApiClientAssignments: [] + ProductAssignments: [] + CatalogAssignments: + - CatalogID: PlayShopPublic + BuyerID: buyer1 + ViewAllCategories: true + ViewAllProducts: true + ProductCatalogAssignment: + - CatalogID: PlayShopPublic + ProductID: PSPAMMWSSRCJ + - CatalogID: PlayShopPublic + ProductID: PSPCCBB + - CatalogID: PlayShopPublic + ProductID: PSPCCBHB + - CatalogID: PlayShopPublic + ProductID: PSPCCBLSFRBPLED + - CatalogID: PlayShopPublic + ProductID: PSPCCCBL + - CatalogID: PlayShopPublic + ProductID: PSPCCCCBC + - CatalogID: PlayShopPublic + ProductID: PSPCCCHVA + - CatalogID: PlayShopPublic + ProductID: PSPCCFLEDBBL + - CatalogID: PlayShopPublic + ProductID: PSPCCFP + - CatalogID: PlayShopPublic + ProductID: PSPCCHP + - CatalogID: PlayShopPublic + ProductID: PSPCCM + - CatalogID: PlayShopPublic + ProductID: PSPCCMBDL + - CatalogID: PlayShopPublic + ProductID: PSPCCPGS + - CatalogID: PlayShopPublic + ProductID: PSPCCR + - CatalogID: PlayShopPublic + ProductID: PSPCCRBLEDBL + - CatalogID: PlayShopPublic + ProductID: PSPCCSB + - CatalogID: PlayShopPublic + ProductID: PSPCCSBPC + - CatalogID: PlayShopPublic + ProductID: PSPCCT + - CatalogID: PlayShopPublic + ProductID: PSPCCTP + - CatalogID: PlayShopPublic + ProductID: PSPCCW + - CatalogID: PlayShopPublic + ProductID: PSPCCWBBC + - CatalogID: PlayShopPublic + ProductID: PSPCCWC + - CatalogID: PlayShopPublic + ProductID: PSPCCWSH + - CatalogID: PlayShopPublic + ProductID: PSPGG + - CatalogID: PlayShopPublic + ProductID: PSPGSB + - CatalogID: PlayShopPublic + ProductID: PSPOBITPRK + - CatalogID: PlayShopPublic + ProductID: PSPOBLS + - CatalogID: PlayShopPublic + ProductID: PSPOTG10CSWSB + - CatalogID: PlayShopPublic + ProductID: PSPOTGGBR + - CatalogID: PlayShopPublic + ProductID: PSPOUS + - CatalogID: PlayShopPublic + ProductID: PSPOUSC + - CatalogID: PlayShopPublic + ProductID: PSPOUT + - CatalogID: PlayShopPublic + ProductID: PSPOYFM + - CatalogID: PlayShopPublic + ProductID: PSPPRBSWPP + - CatalogID: PlayShopPublic + ProductID: PSPPSGCS + - CatalogID: PlayShopPublic + ProductID: PSPPSSGB + - CatalogID: PlayShopPublic + ProductID: PSPRFSAW + - CatalogID: PlayShopPublic + ProductID: PSPRRTCB + - CatalogID: PlayShopPublic + ProductID: PSPRRTEKS + - CatalogID: PlayShopPublic + ProductID: PSPRRTGWB + - CatalogID: PlayShopPublic + ProductID: PSPSBABW + - CatalogID: PlayShopPublic + ProductID: PSPSBBC + - CatalogID: PlayShopPublic + ProductID: PSPSBC + - CatalogID: PlayShopPublic + ProductID: PSPSBGPS + - CatalogID: PlayShopPublic + ProductID: PSPSBL + - CatalogID: PlayShopPublic + ProductID: PSPSBPH + - CatalogID: PlayShopPublic + ProductID: PSPSCBP + - CatalogID: PlayShopPublic + ProductID: PSPSCBS + - CatalogID: PlayShopPublic + ProductID: PSPSCHF + - CatalogID: PlayShopPublic + ProductID: PSPSCMEKGM + - CatalogID: PlayShopPublic + ProductID: PSPSCN + - CatalogID: PlayShopPublic + ProductID: PSPSCP + - CatalogID: PlayShopPublic + ProductID: PSPSCW + - CatalogID: PlayShopPublic + ProductID: PSPSDP + - CatalogID: PlayShopPublic + ProductID: PSPSEBS + - CatalogID: PlayShopPublic + ProductID: PSPSGS + - CatalogID: PlayShopPublic + ProductID: PSPSRG + - CatalogID: PlayShopPublic + ProductID: PSPSSAB + - CatalogID: PlayShopPublic + ProductID: PSPSSB + - CatalogID: PlayShopPublic + ProductID: PSPSSFT + - CatalogID: PlayShopPublic + ProductID: PSPSSS + SpecProductAssignments: + - SpecID: PSPAMMWSSRCJ-Color + ProductID: PSPAMMWSSRCJ + DefaultValue: null + DefaultOptionID: null + - SpecID: PSPAMMWSSRCJ-Size + ProductID: PSPAMMWSSRCJ + DefaultValue: null + DefaultOptionID: null + PromotionAssignment: [] \ No newline at end of file diff --git a/public/404 Error-pana.svg b/public/404 Error-pana.svg new file mode 100644 index 00000000..b2a6d853 --- /dev/null +++ b/public/404 Error-pana.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/Background.jpg b/public/Background.jpg new file mode 100644 index 00000000..207e6dd2 Binary files /dev/null and b/public/Background.jpg differ diff --git a/public/Background.png b/public/Background.png new file mode 100644 index 00000000..cc49911a Binary files /dev/null and b/public/Background.png differ diff --git a/public/Brand_Logo.png b/public/Brand_Logo.png new file mode 100644 index 00000000..9bc52b58 Binary files /dev/null and b/public/Brand_Logo.png differ diff --git a/public/Brand_Logo_White.png b/public/Brand_Logo_White.png new file mode 100644 index 00000000..4960b64e Binary files /dev/null and b/public/Brand_Logo_White.png differ diff --git a/public/Launching-amico.svg b/public/Launching-amico.svg new file mode 100644 index 00000000..404573c1 --- /dev/null +++ b/public/Launching-amico.svg @@ -0,0 +1 @@ +START \ No newline at end of file diff --git a/public/Products.jpg b/public/Products.jpg new file mode 100644 index 00000000..bb0697b2 Binary files /dev/null and b/public/Products.jpg differ diff --git a/public/Promotions.jpg b/public/Promotions.jpg new file mode 100644 index 00000000..ffe2a620 Binary files /dev/null and b/public/Promotions.jpg differ diff --git a/public/chakra-ui-logomark-colored.svg b/public/chakra-ui-logomark-colored.svg new file mode 100644 index 00000000..620fe263 --- /dev/null +++ b/public/chakra-ui-logomark-colored.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000..4965832f Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/images/HeroBackground.jpg b/public/images/HeroBackground.jpg new file mode 100644 index 00000000..94bd31b5 Binary files /dev/null and b/public/images/HeroBackground.jpg differ diff --git a/public/images/SidebarHelpImage.png b/public/images/SidebarHelpImage.png new file mode 100644 index 00000000..31e01749 Binary files /dev/null and b/public/images/SidebarHelpImage.png differ diff --git a/public/images/avatars/avatar1.png b/public/images/avatars/avatar1.png new file mode 100644 index 00000000..ac03d838 Binary files /dev/null and b/public/images/avatars/avatar1.png differ diff --git a/public/images/avatars/avatar10.png b/public/images/avatars/avatar10.png new file mode 100644 index 00000000..5354688c Binary files /dev/null and b/public/images/avatars/avatar10.png differ diff --git a/public/images/avatars/avatar2.png b/public/images/avatars/avatar2.png new file mode 100644 index 00000000..bed4822a Binary files /dev/null and b/public/images/avatars/avatar2.png differ diff --git a/public/images/avatars/avatar3.png b/public/images/avatars/avatar3.png new file mode 100644 index 00000000..0101d3b3 Binary files /dev/null and b/public/images/avatars/avatar3.png differ diff --git a/public/images/avatars/avatar4.png b/public/images/avatars/avatar4.png new file mode 100644 index 00000000..c4206b86 Binary files /dev/null and b/public/images/avatars/avatar4.png differ diff --git a/public/images/avatars/avatar5.png b/public/images/avatars/avatar5.png new file mode 100644 index 00000000..3e314015 Binary files /dev/null and b/public/images/avatars/avatar5.png differ diff --git a/public/images/avatars/avatar6.png b/public/images/avatars/avatar6.png new file mode 100644 index 00000000..66f7bc1e Binary files /dev/null and b/public/images/avatars/avatar6.png differ diff --git a/public/images/avatars/avatar7.png b/public/images/avatars/avatar7.png new file mode 100644 index 00000000..1a357150 Binary files /dev/null and b/public/images/avatars/avatar7.png differ diff --git a/public/images/avatars/avatar8.png b/public/images/avatars/avatar8.png new file mode 100644 index 00000000..e3759db8 Binary files /dev/null and b/public/images/avatars/avatar8.png differ diff --git a/public/images/avatars/avatar9.png b/public/images/avatars/avatar9.png new file mode 100644 index 00000000..c79ef85b Binary files /dev/null and b/public/images/avatars/avatar9.png differ diff --git a/public/images/banner-01.png b/public/images/banner-01.png new file mode 100644 index 00000000..7bf53585 Binary files /dev/null and b/public/images/banner-01.png differ diff --git a/public/images/banner-02.png b/public/images/banner-02.png new file mode 100644 index 00000000..7bae6355 Binary files /dev/null and b/public/images/banner-02.png differ diff --git a/public/images/dummy-image-square.jpg b/public/images/dummy-image-square.jpg new file mode 100644 index 00000000..8e84d66e Binary files /dev/null and b/public/images/dummy-image-square.jpg differ diff --git a/public/images/hero_sample.jpg b/public/images/hero_sample.jpg new file mode 100644 index 00000000..287da77e Binary files /dev/null and b/public/images/hero_sample.jpg differ diff --git a/public/images/icon_order.png b/public/images/icon_order.png new file mode 100644 index 00000000..425a1413 Binary files /dev/null and b/public/images/icon_order.png differ diff --git a/public/images/icon_product.png b/public/images/icon_product.png new file mode 100644 index 00000000..4724bd5d Binary files /dev/null and b/public/images/icon_product.png differ diff --git a/public/images/icon_promo.png b/public/images/icon_promo.png new file mode 100644 index 00000000..11c6f5ea Binary files /dev/null and b/public/images/icon_promo.png differ diff --git a/public/images/icon_user.png b/public/images/icon_user.png new file mode 100644 index 00000000..bc637c84 Binary files /dev/null and b/public/images/icon_user.png differ diff --git a/public/images/iphone.jpg b/public/images/iphone.jpg new file mode 100644 index 00000000..dc0710b4 Binary files /dev/null and b/public/images/iphone.jpg differ diff --git a/public/images/purse.jpg b/public/images/purse.jpg new file mode 100644 index 00000000..fbd94725 Binary files /dev/null and b/public/images/purse.jpg differ diff --git a/public/images/sampleblogphoto.jpg b/public/images/sampleblogphoto.jpg new file mode 100644 index 00000000..1f50b000 Binary files /dev/null and b/public/images/sampleblogphoto.jpg differ diff --git a/public/images/samplecategory.jpg b/public/images/samplecategory.jpg new file mode 100644 index 00000000..41ef75e4 Binary files /dev/null and b/public/images/samplecategory.jpg differ diff --git a/public/images/vendorlogo.jpg b/public/images/vendorlogo.jpg new file mode 100644 index 00000000..e3b7268c Binary files /dev/null and b/public/images/vendorlogo.jpg differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..a08eb360 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,12 @@ +{ + "short_name": "OC-Seller-App", + "name": "OC-Seller-App", + "lang": "en", + "start_url": "/", + "background_color": "#FFFFFF", + "theme_color": "#FFFFFF", + "dir": "ltr", + "display": "standalone", + "orientation": "portrait", + "prefer_related_applications": false +} \ No newline at end of file diff --git a/public/next-app-chakra-ts.png b/public/next-app-chakra-ts.png new file mode 100644 index 00000000..1888694d Binary files /dev/null and b/public/next-app-chakra-ts.png differ diff --git a/public/nextjs-black-logo.svg b/public/nextjs-black-logo.svg new file mode 100644 index 00000000..312dc61b --- /dev/null +++ b/public/nextjs-black-logo.svg @@ -0,0 +1,18 @@ + + + + next-black + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/nextjs-icon-dark.svg b/public/nextjs-icon-dark.svg new file mode 100644 index 00000000..99afa341 --- /dev/null +++ b/public/nextjs-icon-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/nextjs-icon-light.svg b/public/nextjs-icon-light.svg new file mode 100644 index 00000000..c45d6e63 --- /dev/null +++ b/public/nextjs-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/ts-logo-512.svg b/public/ts-logo-512.svg new file mode 100644 index 00000000..a46d53d4 --- /dev/null +++ b/public/ts-logo-512.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 00000000..fbf0e25a --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/lib/api/addresses.ts b/src/lib/api/addresses.ts new file mode 100644 index 00000000..17a35509 --- /dev/null +++ b/src/lib/api/addresses.ts @@ -0,0 +1,37 @@ +import {Addresses} from "ordercloud-javascript-sdk" + +export const addressesService = { + list, + getById, + create, + update, + delete: _delete +} + +async function list(buyerID) { + console.log("userGroupsService::List") + return await Addresses.List(buyerID) +} + +async function getById(buyerID, addressID) { + console.log("userGroupsService::getById") + return await Addresses.Get(buyerID, addressID) +} + +async function create(buyerID, address) { + console.log("userGroups::create") + return await Addresses.Create(buyerID, address) +} + +async function update(buyerID, addressID, address) { + console.log("buyersService::update") + return await Addresses.Patch(buyerID, addressID, address) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(buyerID, addressID) { + console.log("buyersService::_delete") + if (buyerID) { + return await Addresses.Delete(buyerID, addressID) + } +} diff --git a/src/lib/api/buyers.ts b/src/lib/api/buyers.ts new file mode 100644 index 00000000..93e303f9 --- /dev/null +++ b/src/lib/api/buyers.ts @@ -0,0 +1,45 @@ +import {Buyers, Catalogs, UserGroups, Users} from "ordercloud-javascript-sdk" + +export const buyersService = { + list, + getById, + create, + update, + delete: _delete +} + +async function list(filters?: any) { + console.log("buyersService::List") + return await Buyers.List(filters) +} + +async function getById(buyerID) { + console.log("buyersService::getById") + return await Buyers.Get(buyerID) +} + +async function create(buyer) { + console.log("buyersService::create") + //Demo sample : By default OrderCloud will assign a unique ID to the new created buyer. + //Customizing the ID generation business logic here for Demo purpose. + buyer.ID = buyer.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + return await Buyers.Create(buyer) +} + +async function update(buyer) { + console.log("buyersService::update") + return await Buyers.Patch(buyer.ID, buyer) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(buyerID) { + console.log("buyersService::_delete") + if (buyerID) { + return await Buyers.Delete(buyerID) + } +} diff --git a/src/lib/api/catalogs.ts b/src/lib/api/catalogs.ts new file mode 100644 index 00000000..286fb9b7 --- /dev/null +++ b/src/lib/api/catalogs.ts @@ -0,0 +1,76 @@ +import {CatalogAssignment, Catalogs} from "ordercloud-javascript-sdk" + +export const catalogsService = { + list, + listAssignements, + getById, + create, + update, + delete: _delete, + getCatalogsCountByBuyerID, + getCatalogsbyBuyerID, + saveAssignment +} + +async function list(filters?) { + console.log("catalogsService::List") + return await Catalogs.List(filters) +} + +async function listAssignements(buyerID) { + console.log("catalogsService::ListAssignement") + return await Catalogs.ListAssignments({buyerID: buyerID}) +} + +async function getById(catalogID) { + console.log("catalogsService::getById") + return await Catalogs.Get(catalogID) +} + +async function create(catalog) { + console.log("catalogsService::create") + return await Catalogs.Create(catalog) +} + +async function update(catalog) { + console.log("catalogsService::update") + return await Catalogs.Patch(catalog.ID, catalog) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(catalogID) { + console.log("catalogsService::_delete") + if (catalogID) { + return await Catalogs.Delete(catalogID) + } +} + +async function getCatalogsCountByBuyerID(buyerID) { + console.log("catalogsService::getCatalogsCountByBuyerId") + if (buyerID) { + const catalogsList = await Catalogs.ListAssignments({buyerID: buyerID}) + return catalogsList?.Meta?.TotalCount + } else return 0 +} + +async function getCatalogsbyBuyerID(buyerID) { + console.log("catalogsService::getCatalogsbyBuyerId") + const catalogsAssignments = await Catalogs.ListAssignments({buyerID: buyerID}) + let catalogAssignmentsIds = catalogsAssignments.Items.map((item) => item.CatalogID) + const catalogsList = await Catalogs.List({ + filters: {ID: catalogAssignmentsIds.join("|")} + }) + return catalogsList +} + +async function saveAssignment(buyerID, catalogID) { + console.log("catalogsService::createCatalogAssignment") + let catalogAssignement: CatalogAssignment + catalogAssignement = { + CatalogID: catalogID, + BuyerID: buyerID, + ViewAllCategories: true, // Default Value for Demo purpose + ViewAllProducts: true // Default Value for Demo purpose + } + return await Catalogs.SaveAssignment(catalogAssignement) +} diff --git a/src/lib/api/categories.ts b/src/lib/api/categories.ts new file mode 100644 index 00000000..401f0256 --- /dev/null +++ b/src/lib/api/categories.ts @@ -0,0 +1,84 @@ +import {Categories, CategoryAssignment} from "ordercloud-javascript-sdk" + +export const categoriesService = { + list, + listAssignements, + getById, + create, + update, + delete: _delete, + getCategoriesCountByCatalogID, + saveAssignment +} + +async function list(catalogID) { + console.log("categoriesService::List") + return await Categories.List(catalogID, {depth: "all", pageSize: 100}) +} + +async function listAssignements(catalogID) { + console.log("categoriesService::ListAssignement") + return await Categories.ListAssignments(catalogID) +} + +async function getById(catalogID, categoryID) { + console.log("categoriesService::getById") + console.log(categoryID) + return await Categories.Get(catalogID, categoryID) +} + +async function create(catalogID, category) { + console.log("categoriesService::create") + return await Categories.Create(catalogID, category) +} + +async function update(catalogID, category) { + console.log("categoriesService::update") + return await Categories.Patch(catalogID, category.ID, category) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(catalogID, categoryID) { + console.log("categoriesService::_delete") + if (catalogID) { + return await Categories.Delete(catalogID, categoryID) + } +} + +async function getCategoriesCountByCatalogID(catalogID) { + console.log("categoriesService::getCatalogsCountByBuyerId") + if (catalogID) { + const categoriesList = await Categories.List(catalogID) + return categoriesList?.Meta?.TotalCount + } else return 0 +} + +// async function getCategoriesbyCatalogID(buyerID) { +// console.log("catalogsService::getCatalogsbyBuyerId") +// const catalogsAssignments = await Categories.ListAssignments({ +// buyerID: buyerID +// }) +// let catalogAssignmentsIds = catalogsAssignments.Items.map( +// (item) => item.CatalogID +// ) +// console.log(catalogAssignmentsIds) +// const catalogsList = await Categories.List({ +// filters: {ID: catalogAssignmentsIds.join("|")} +// }) +// console.log(catalogsList) +// return catalogsList +// } + +async function saveAssignment(catalogID, categoryID, buyerID, userGroupID) { + console.log("categoriesService::createCatalogAssignment") + let categoryAssignement: CategoryAssignment + categoryAssignement = { + CategoryID: categoryID, + BuyerID: buyerID, + UserGroupID: userGroupID, + Visible: true, // Default Value for Demo purpose + ViewAllProducts: true // Default Value for Demo purpose + } + + return await Categories.SaveAssignment(catalogID, categoryAssignement) +} diff --git a/src/lib/api/dashboard.ts b/src/lib/api/dashboard.ts new file mode 100644 index 00000000..588ab18f --- /dev/null +++ b/src/lib/api/dashboard.ts @@ -0,0 +1,226 @@ +import {Orders, Users} from "ordercloud-javascript-sdk" + +const d = new Date() +let day = d.getDate() +let month = d.getMonth() + 1 //Need the plus 1 since it is an array of 0-11 +let year = d.getFullYear() +let previousMonth = d.getMonth() +if (d.getMonth() === 0) { + previousMonth = 11 +} +let previousMonthYear = d.getFullYear() +if (d.getMonth() === 0) { + previousMonthYear = d.getFullYear() - 1 +} +let mockData +if (!process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + mockData = require("../mockdata/dashboard_data.json") +} + +export const dashboardService = { + getTodaysMoney, + getPreviousTodaysMoney, + getTotalSales, + getPreviousTotalSales, + getTotalUsers, + getPreviousTotalUsers, + getTotalNewUsers, + getPreviousTotalNewUsers + //, + //getTotalSalesByMonth, + //getTotalSalesPreviousYearByMonth +} + +async function getTodaysMoney() { + // Total Sales todate this month + //console.log("dashboardService::getTodaysSales") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const ordersList = await Orders.List("All", { + filters: { + DateCreated: [">" + year + "-" + month + "-01", "<" + year + "-" + month + "-" + day] + } + }) + result = ordersList.Items.reduce((accumulator, obj) => { + return accumulator + obj.Total + }, 0) + } else { + result = mockData.todaysmoney.totalamount + } + return await result +} + +async function getPreviousTodaysMoney() { + // Total Sales todate last month + // console.log("dashboardService::getPreviousTodaysSales") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const ordersList = await Orders.List("All", { + filters: { + DateCreated: [ + ">" + previousMonthYear + "-" + previousMonth + "-01", + "<" + previousMonthYear + "-" + previousMonth + "-" + day + ] + } + }) + result = ordersList.Items.reduce((accumulator, obj) => { + return accumulator + obj.Total + }, 0) + } else { + result = mockData.todaysmoney.previoustotalamount + } + + return await result +} + +async function getTotalSales() { + // Total Sales todate this month + // console.log("dashboardService::getTotalSales") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const ordersList = await Orders.List("All", { + filters: { + DateCreated: [">" + year + "-01-01", "<" + year + "-" + month + "-" + day] + } + }) + result = ordersList.Items.reduce((accumulator, obj) => { + return accumulator + obj.Total + }, 0) + } else { + result = mockData.totalsales.totalamount + } + + return await result +} + +async function getPreviousTotalSales() { + // Total Sales todate last month + // console.log("dashboardService::getPreviousTotalSales") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const ordersList = await Orders.List("All", { + filters: { + DateCreated: [">" + (year - 1) + "-01-01", "<" + (year - 1) + "-" + month + "-" + day] + } + }) + result = ordersList.Items.reduce((accumulator, obj) => { + return accumulator + obj.Total + }, 0) + } else { + result = mockData.totalsales.previoustotalamount + } + + return await result +} + +// async function getTotalSalesByMonth() { +// // Total Sales todate this month +// // console.log("dashboardService::getTotalSales") +// const ordersList = await Orders.List("All", { +// filters: { +// DateCreated: [">" + year + "-01-01", "<" + year + "-" + month + "-" + day] +// } +// }) +// const result = ordersList.Items.reduce((accumulator, obj) => { +// return accumulator + accumulator + obj.Total +// }, 0) +// return await result +// } + +// async function getTotalSalesPreviousYearByMonth() { +// // Total Sales todate last month +// // console.log("dashboardService::getPreviousTotalSales") +// const ordersList = await Orders.List("All", { +// filters: { +// DateCreated: [ +// ">" + (year - 1) + "-01-01", +// "<" + (year - 1) + "-" + month + "-" + day +// ] +// } +// }) +// const result = ordersList.Items.reduce((accumulator, obj) => { +// return accumulator + obj.Total +// }, 0) +// return await result +// } + +async function getTotalUsers() { + // Total Users todate this month + //console.log("dashboardService::getTotalUsers") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const usersList = await Orders.List("All", { + filters: { + DateCreated: [">" + year + "-01-01", "<" + year + "-" + month + "-" + day] + } + }) + result = usersList.Items.length + } else { + result = mockData.totalusers.totalamount + } + + return await result +} +async function getPreviousTotalUsers() { + // Total Users todate this month + //console.log("dashboardService::getTotalUsers") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const usersList = await Orders.List("All", { + filters: { + DateCreated: [">" + (year - 1) + "-01-01", "<" + (year - 1) + "-" + month + "-" + day] + } + }) + result = usersList.Items.length + } else { + result = mockData.totalusers.previoustotalamount + } + + return await result +} + +async function getTotalNewUsers() { + // Total Users todate this month + //console.log("dashboardService::getTotalUsers") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const usersList = await Orders.List("All", { + filters: { + DateCreated: [">" + year + "-01-01", "<" + year + "-" + month + "-" + day] + } + }) + result = usersList.Items.length + } else { + result = mockData.newusers.totalamount + } + + return await result +} +async function getPreviousTotalNewUsers() { + // Total Users todate this month + //console.log("dashboardService::getTotalUsers") + let result + + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + const usersList = await Orders.List("All", { + filters: { + DateCreated: [ + ">" + previousMonthYear + "-" + previousMonth + "-01", + "<" + previousMonthYear + "-" + previousMonth + "-" + day + ] + } + }) + result = usersList.Items.length + } else { + result = mockData.newusers.previoustotalamount + } + + return await result +} diff --git a/src/lib/api/index.tsx b/src/lib/api/index.tsx new file mode 100644 index 00000000..b38ab153 --- /dev/null +++ b/src/lib/api/index.tsx @@ -0,0 +1,14 @@ +export * from "./addresses" +export * from "./buyers" +export * from "./catalogs" +export * from "./categories" +export * from "./dashboard" +export * from "./orders" +export * from "./products" +export * from "./promotions" +export * from "./returns" +export * from "./suppliers" +export * from "./supplierUserGroups" +export * from "./supplierUsers" +export * from "./usergroups" +export * from "./users" diff --git a/src/lib/api/orders.ts b/src/lib/api/orders.ts new file mode 100644 index 00000000..c56afb25 --- /dev/null +++ b/src/lib/api/orders.ts @@ -0,0 +1,48 @@ +import {IntegrationEvents, OrderWorksheet, Orders} from "ordercloud-javascript-sdk" + +export const ordersService = { + list, + getById, + create, + update + //delete: _delete +} + +async function list() { + //console.log("ordersService::getAll") + return await Orders.List("All") +} + +async function getById(id) { + //console.log("ordersService::getById") + const worksheet = await IntegrationEvents.GetWorksheet("All", id) + return await worksheet +} + +async function create(fields) { + //console.log("ordersService::create") + //console.log(fields) + //Demo sample : By default OrderCloud will assign a unique ID to the newly created order. + //Customizing the ID generation business logic here for Demo purpose. + fields.ID = fields.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + //Orders.Create(fields) +} + +async function update(fields) { + //console.log("ordersService::update") + //console.log(fields) + //Orders.Patch(fields.ID, fields) +} + +// prefixed with underscored because delete is a reserved word in javascript +// async function _delete(id) { +// //console.log("ordersService::_delete") +// if (id) { +// return await Orders.Delete(id) +// } +// } diff --git a/src/lib/api/productfacets.ts b/src/lib/api/productfacets.ts new file mode 100644 index 00000000..a7174f77 --- /dev/null +++ b/src/lib/api/productfacets.ts @@ -0,0 +1,49 @@ +import {ProductFacets} from "ordercloud-javascript-sdk" + +export const productfacetsService = { + getAll, + getById, + create, + update, + delete: _delete +} + +async function getAll(filters?) { + //console.log("productfacetsService::getAll") + return await ProductFacets.List(filters) +} + +async function getById(id) { + //console.log("productfacetsService::getById") + return await ProductFacets.Get(id) +} + +async function create(fields) { + //console.log("productfacetsService::create") + //console.log(fields) + //Demo sample : By default OrderCloud will assign a unique ID to the newly created facet. + //Customizing the ID generation business logic here for Demo purpose. + fields.ID = fields.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + //Assign the XP PAth to the facet and field name + fields.XpPath = "Facets." + fields.ID + ProductFacets.Create(fields) +} + +async function update(fields) { + //console.log("productfacetsService::update") + //console.log(fields) + ProductFacets.Patch(fields.ID, fields) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(id) { + //console.log("productfacetsService::_delete") + if (id) { + return await ProductFacets.Delete(id) + } +} diff --git a/src/lib/api/products.ts b/src/lib/api/products.ts new file mode 100644 index 00000000..97f011d3 --- /dev/null +++ b/src/lib/api/products.ts @@ -0,0 +1,47 @@ +import {Products} from "ordercloud-javascript-sdk" + +export const productsService = { + list, + getById, + create, + update, + delete: _delete +} + +async function list() { + //console.log("productsService::getAll") + return await Products.List() +} + +async function getById(id) { + //console.log("productsService::getById") + return await Products.Get(id) +} + +async function create(fields) { + //console.log("productsService::create") + //console.log(fields) + //Demo sample : By default OrderCloud will assign a unique ID to the newly created product. + //Customizing the ID generation business logic here for Demo purpose. + fields.ID = fields.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + //Products.Create(fields) +} + +async function update(fields) { + //console.log("productsService::update") + //console.log(fields) + //Products.Patch(fields.ID, fields) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(id) { + //console.log("productsService::_delete") + if (id) { + return await Products.Delete(id) + } +} diff --git a/src/lib/api/promotions.ts b/src/lib/api/promotions.ts new file mode 100644 index 00000000..901be938 --- /dev/null +++ b/src/lib/api/promotions.ts @@ -0,0 +1,104 @@ +import {Promotions} from "ordercloud-javascript-sdk" + +export const promotionsService = { + list, + getById, + create, + update, + delete: _delete, + buildEligibleExpression, + buildValueExpression +} + +async function list(filters?) { + console.log("promotionsService::getAll") + return await Promotions.List(filters) +} + +async function getById(id) { + console.log("promotionsService::getById") + console.log(id) + return await Promotions.Get(id) +} + +async function create(fields) { + console.log("promotionsService::create") + //Demo sample : By default OrderCloud will assign a unique ID to the newly created order. + //Customizing the ID generation business logic here for Demo purpose. + fields.ID = fields.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + console.log(fields) + Promotions.Create(fields) +} + +async function update(fields) { + //console.log("promotionsService::update") + //console.log(fields) + Promotions.Patch(fields.ID, fields) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(id) { + //console.log("promotionsService::_delete") + if (id) { + return await Promotions.Delete(id) + } +} + +// Expressions Structure +// Propreties +//items.any() (true if any item on the order matches filter) +//items.all() (true if all items match filter) +//items.quantity()(returns sum of line item quantities matching your specified condition) +//items.count()(returns number of line items on the order matching your specified condition) +//items.total() (compare result to a dollar amount) + +// Simplistic Example to close the loop - then we will use the dnd Expression UI Builder and match to this +async function buildEligibleExpression(fields) { + console.log("buildEligibleExpression") + let eligibleExpression = "" //Default value when no condition has been specified. + // Minimum Requirements has been selected + switch (fields.xp_MinimumReq) { + case "min-amount": { + eligibleExpression = `order.Subtotal>= ${fields.xp_MinReqValue}` + break + } + case "min-qty": { + eligibleExpression = `items.quantity()>= ${fields.xp_MinReqValue}` + break + } + default: { + eligibleExpression = "true" + break + } + } + + return eligibleExpression +} + +// Simplistic Example to close the loop - then we will use the dnd Expression UI Builder and match to this +async function buildValueExpression(fields) { + let valueExpression = "0" //Default value when no condition has been specified. + switch (fields.xp_Type) { + case "Percentage": { + valueExpression = `order.Subtotal * ${fields.xp_Value / 100})` + break + } + case "Fixed": { + valueExpression = `${fields.xp_Value}` + break + } + case "Free-shipping": { + valueExpression = `order.ShippingCost` + } + + default: { + valueExpression = "0" + break + } + } + return valueExpression +} diff --git a/src/lib/api/returns.ts b/src/lib/api/returns.ts new file mode 100644 index 00000000..b058d1e3 --- /dev/null +++ b/src/lib/api/returns.ts @@ -0,0 +1,47 @@ +import {OrderReturn, OrderReturns, Payment, Payments} from "ordercloud-javascript-sdk" + +export const returnsService = { + getAll, + getById, + create, + update, + delete: _delete +} + +async function getAll() { + //console.log("returnsService::getAll") + return await OrderReturns.List() +} + +async function getById(id) { + //console.log("returnsService::getById") + return await OrderReturns.Get(id) +} + +async function create(fields) { + //console.log("returnsService::create") + //console.log(fields) + //Demo sample : By default OrderCloud will assign a unique ID to the newly created order. + //Customizing the ID generation business logic here for Demo purpose. + fields.ID = fields.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + //OrderReturns.Create(fields) +} + +async function update(fields) { + //console.log("returnsService::update") + //console.log(fields) + //OrderReturns.Patch(fields.ID, fields) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(id) { + //console.log("returnsService::_delete") + if (id) { + return await OrderReturns.Delete(id) + } +} diff --git a/src/lib/api/supplierUserGroups.ts b/src/lib/api/supplierUserGroups.ts new file mode 100644 index 00000000..ca1b69b0 --- /dev/null +++ b/src/lib/api/supplierUserGroups.ts @@ -0,0 +1,54 @@ +import {SupplierUserGroups} from "ordercloud-javascript-sdk" + +export const supplierUserGroupsService = { + list, + getById, + create, + update, + delete: _delete, + getSuppliersUserGroupsCount +} + +async function list(supplierID, filters?) { + console.log("userGroupsService::List") + return await SupplierUserGroups.List(supplierID, filters) +} + +async function getById(supplierID, userGroupID) { + console.log("userGroupsService::getById") + return await SupplierUserGroups.Get(supplierID, userGroupID) +} + +async function create(supplierID, userGroup) { + console.log("userGroups::create") + //Demo sample : By default OrderCloud will assign a unique ID to the new created supplier. + //Customizing the ID generation business logic here for Demo purpose. + userGroup.ID = userGroup.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + return await SupplierUserGroups.Create(supplierID, userGroup) +} + +async function update(supplierID, userGroupID, userGroup) { + console.log("suppliersService::update") + return await SupplierUserGroups.Patch(supplierID, userGroupID, userGroup) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(supplierID, userGroupID) { + console.log("suppliersService::_delete") + if (supplierID) { + return await SupplierUserGroups.Delete(supplierID, userGroupID) + } +} + +async function getSuppliersUserGroupsCount(supplierID) { + console.log("suppliersService::getSupplierUserGroupsCountBySupplierID") + if (supplierID) { + const userGroupsList = await SupplierUserGroups.List(supplierID) + return userGroupsList?.Meta?.TotalCount + } else return 0 +} diff --git a/src/lib/api/supplierUsers.ts b/src/lib/api/supplierUsers.ts new file mode 100644 index 00000000..a45e6001 --- /dev/null +++ b/src/lib/api/supplierUsers.ts @@ -0,0 +1,48 @@ +import {SupplierUsers} from "ordercloud-javascript-sdk" + +export const supplierUsersService = { + list, + getById, + create, + update, + delete: _delete, + getSuppliersUsersCount +} + +async function list(supplierID, filters?) { + console.log("usersService::List") + return await SupplierUsers.List(supplierID, filters) +} + +async function getById(supplierID, userID) { + console.log("usersService::getById") + return await SupplierUsers.Get(supplierID, userID) +} + +async function create(supplierID, user) { + console.log("users::create") + //Demo sample : By default OrderCloud will assign a unique ID to the new created supplier. + //Customizing the ID generation business logic here for Demo purpose. + user.ID = user.SupplierUsername + return await SupplierUsers.Create(supplierID, user) +} + +async function update(supplierID, userID, user) { + console.log("SupplierUsersService::update") + return await SupplierUsers.Patch(supplierID, userID, user) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(supplierID, userID) { + console.log("SupplierUsersService::_delete") + if (supplierID) { + return await SupplierUsers.Delete(supplierID, userID) + } +} +async function getSuppliersUsersCount(supplierID) { + console.log("suppliersService::getSupplierUsersCount") + if (supplierID) { + const usersList = await SupplierUsers.List(supplierID) + return usersList?.Meta?.TotalCount + } else return 0 +} diff --git a/src/lib/api/suppliers.ts b/src/lib/api/suppliers.ts new file mode 100644 index 00000000..7afe60e3 --- /dev/null +++ b/src/lib/api/suppliers.ts @@ -0,0 +1,45 @@ +import {Catalogs, Suppliers, UserGroups, Users} from "ordercloud-javascript-sdk" + +export const suppliersService = { + list, + getById, + create, + update, + delete: _delete +} + +async function list(filters?) { + console.log("suppliersService::List") + return await Suppliers.List(filters) +} + +async function getById(supplierID) { + console.log("suppliersService::getById") + return await Suppliers.Get(supplierID) +} + +async function create(supplier) { + console.log("suppliersService::create") + //Demo sample : By default OrderCloud will assign a unique ID to the new created supplier. + //Customizing the ID generation business logic here for Demo purpose. + supplier.ID = supplier.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + return await Suppliers.Create(supplier) +} + +async function update(supplier) { + console.log("suppliersService::update") + return await Suppliers.Patch(supplier.ID, supplier) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(supplierID) { + console.log("suppliersService::_delete") + if (supplierID) { + return await Suppliers.Delete(supplierID) + } +} diff --git a/src/lib/api/usergroups.ts b/src/lib/api/usergroups.ts new file mode 100644 index 00000000..1cad8701 --- /dev/null +++ b/src/lib/api/usergroups.ts @@ -0,0 +1,54 @@ +import {UserGroups} from "ordercloud-javascript-sdk" + +export const userGroupsService = { + list, + getById, + create, + update, + delete: _delete, + getUserGroupsCountByBuyerID +} + +async function list(buyerID, filters?) { + console.log("userGroupsService::List") + return await UserGroups.List(buyerID, filters) +} + +async function getById(buyerID, userGroupID) { + console.log("userGroupsService::getById") + return await UserGroups.Get(buyerID, userGroupID) +} + +async function create(buyerID, userGroup) { + console.log("userGroups::create") + //Demo sample : By default OrderCloud will assign a unique ID to the new created buyer. + //Customizing the ID generation business logic here for Demo purpose. + userGroup.ID = userGroup.Name.toLowerCase() + .trim() + .replace(/[^\w\s-]/g, "") + .replace(/[\s_-]+/g, "-") + .replace(/^-+|-+$/g, "") + + return await UserGroups.Create(buyerID, userGroup) +} + +async function update(buyerID, userGroupID, userGroup) { + console.log("buyersService::update") + return await UserGroups.Patch(buyerID, userGroupID, userGroup) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(buyerID, userGroupID) { + console.log("buyersService::_delete") + if (buyerID) { + return await UserGroups.Delete(buyerID, userGroupID) + } +} + +async function getUserGroupsCountByBuyerID(buyerID) { + console.log("buyersService::getUserGroupsCountByBuyerID") + if (buyerID) { + const userGroupsList = await UserGroups.List(buyerID) + return userGroupsList?.Meta?.TotalCount + } else return 0 +} diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts new file mode 100644 index 00000000..9c1bdf9b --- /dev/null +++ b/src/lib/api/users.ts @@ -0,0 +1,48 @@ +import {Users} from "ordercloud-javascript-sdk" + +export const usersService = { + list, + getById, + create, + update, + delete: _delete, + getUsersCountByBuyerID +} + +async function list(buyerID, filters?) { + console.log("usersService::List") + return await Users.List(buyerID, filters) +} + +async function getById(buyerID, userID) { + console.log("usersService::getById") + return await Users.Get(buyerID, userID) +} + +async function create(buyerID, user) { + console.log("users::create") + //Demo sample : By default OrderCloud will assign a unique ID to the new created buyer. + //Customizing the ID generation business logic here for Demo purpose. + user.ID = user.Username + return await Users.Create(buyerID, user) +} + +async function update(buyerID, userID, user) { + console.log("UsersService::update") + return await Users.Patch(buyerID, userID, user) +} + +// prefixed with underscored because delete is a reserved word in javascript +async function _delete(buyerID, userID) { + console.log("UsersService::_delete") + if (buyerID) { + return await Users.Delete(buyerID, userID) + } +} +async function getUsersCountByBuyerID(buyerID) { + console.log("buyersService::getUsersCountByBuyerID") + if (buyerID) { + const usersList = await Users.List(buyerID) + return usersList?.Meta?.TotalCount + } else return 0 +} diff --git a/src/lib/components/Chakra.tsx b/src/lib/components/Chakra.tsx new file mode 100644 index 00000000..32343549 --- /dev/null +++ b/src/lib/components/Chakra.tsx @@ -0,0 +1,34 @@ +import {ChakraProvider, localStorageManager} from "@chakra-ui/react" + +import sitecorecommerceTheme from "lib/styles/theme/sitecorecommerce/" +import playsummitTheme from "lib/styles/theme/playsummit/" +import industrialTheme from "lib/styles/theme/industrial/" +import Cookies from "universal-cookie" + +interface ChakraProps { + children: React.ReactNode +} + +export const Chakra = ({children}: ChakraProps) => { + const cookies = new Cookies() + let currenttheme + if (cookies.get("currenttheme") === undefined) { + cookies.set("currenttheme", "lib/styles/theme/sitecorecommerce/", { + path: "/" + }) + } + if (cookies.get("currenttheme") === "lib/styles/theme/sitecorecommerce/") { + currenttheme = sitecorecommerceTheme + } + if (cookies.get("currenttheme") === "lib/styles/theme/playsummit/") { + currenttheme = playsummitTheme + } + if (cookies.get("currenttheme") === "lib/styles/theme/industrial/") { + currenttheme = industrialTheme + } + return ( + + {children} + + ) +} diff --git a/src/lib/components/account/Login.tsx b/src/lib/components/account/Login.tsx new file mode 100644 index 00000000..7011c85a --- /dev/null +++ b/src/lib/components/account/Login.tsx @@ -0,0 +1,114 @@ +import {Box, Button, Checkbox, FormControl, FormLabel, HStack, Heading, Input, Text, VStack} from "@chakra-ui/react" +import {ChangeEvent, FormEvent, FunctionComponent, useCallback, useState} from "react" + +import Card from "../card/Card" +import HeaderLogo from "../branding/HeaderLogo" +import {useAuth} from "lib/hooks/useAuth" + +interface OcLoginFormProps { + title?: string + onLoggedIn: () => void +} + +const OcLoginForm: FunctionComponent = ({title = "Sign into your account", onLoggedIn}) => { + const {Login, isAuthenticated} = useAuth() + const [isLoading, setIsLoading] = useState(false) + const [formValues, setFormValues] = useState({ + identifier: "", + password: "", + remember: false + }) + + const handleInputChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({...v, [fieldKey]: e.target.value})) + } + + const handleCheckboxChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({...v, [fieldKey]: !!e.target.checked})) + } + + const handleSubmit = useCallback( + async (e: FormEvent) => { + try { + setIsLoading(true) + e.preventDefault() + await Login(formValues.identifier, formValues.password, formValues.remember) + onLoggedIn() + } finally { + setIsLoading(false) + } + }, + [formValues.identifier, formValues.password, formValues.remember, onLoggedIn, Login] + ) + + return ( + !isAuthenticated && ( +
    + + + + + {title} + + + {/* TODO Get Errors on Login */} + {/* {error && ( + + + {error.message}{" "} + + )} */} + + + Username + + + + Password + + + + + + + Keep me logged in + + + + + + + +
    + ) + ) +} + +export default OcLoginForm diff --git a/src/lib/components/adminaddresses/CreateUpdateForm.tsx b/src/lib/components/adminaddresses/CreateUpdateForm.tsx new file mode 100644 index 00000000..f860d39a --- /dev/null +++ b/src/lib/components/adminaddresses/CreateUpdateForm.tsx @@ -0,0 +1,109 @@ +import * as Yup from "yup" +import {Box, Button, ButtonGroup, Flex, Stack} from "@chakra-ui/react" +import {Formik} from "formik" +import {InputControl} from "formik-chakra-ui" +import Card from "../card/Card" +import {Address, AdminAddresses} from "ordercloud-javascript-sdk" +import {useRouter} from "next/router" +import {useCreateUpdateForm} from "lib/hooks/useCreateUpdateForm" +import {pick} from "lodash" + +export {CreateUpdateForm} +interface CreateUpdateFormProps { + address?: Address +} +function CreateUpdateForm({address}: CreateUpdateFormProps) { + let router = useRouter() + const formShape = { + AddressName: Yup.string().max(100), + CompanyName: Yup.string().max(100), + FirstName: Yup.string().max(100), + LastName: Yup.string().max(100), + Street1: Yup.string().max(100).required(), + Street2: Yup.string().max(100), + City: Yup.string().max(100).required(), + State: Yup.string().max(100).required(), + Zip: Yup.string().max(100).required(), + Country: Yup.string().max(2).min(2).required(), + Phone: Yup.string().max(100) + } + + const {successToast, validationSchema, initialValues, onSubmit} = useCreateUpdateForm
    ( + address, + formShape, + createAddress, + updateAddress + ) + + async function createAddress(fields: Address) { + await AdminAddresses.Create(fields) + successToast({ + description: "Address created successfully." + }) + router.back() + } + + async function updateAddress(fields: Address) { + const formFields = Object.keys(formShape) + await AdminAddresses.Patch(fields.ID, pick(fields, formFields)) + successToast({ + description: "Address updated successfully" + }) + router.back() + } + + return ( + + + + {({ + // most of the useful available Formik props + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + setFieldValue, + resetForm + }) => ( + + + + + + + + + + + + + + + + + + + + + )} + + + + ) +} diff --git a/src/lib/components/adminaddresses/index.tsx b/src/lib/components/adminaddresses/index.tsx new file mode 100644 index 00000000..24db4ac7 --- /dev/null +++ b/src/lib/components/adminaddresses/index.tsx @@ -0,0 +1 @@ +export * from "./CreateUpdateForm" diff --git a/src/lib/components/adminusers/CreateUpdateForm.tsx b/src/lib/components/adminusers/CreateUpdateForm.tsx new file mode 100644 index 00000000..4f62463b --- /dev/null +++ b/src/lib/components/adminusers/CreateUpdateForm.tsx @@ -0,0 +1,188 @@ +import * as Yup from "yup" +import { + Box, + Button, + ButtonGroup, + Flex, + Heading, + Stack, + Switch, + Table, + TableContainer, + Tbody, + Td, + Tr +} from "@chakra-ui/react" +import {Formik} from "formik" +import {InputControl, SwitchControl} from "formik-chakra-ui" +import Card from "../card/Card" +import {AdminUserGroups, AdminUsers, User} from "ordercloud-javascript-sdk" +import {useRouter} from "next/router" +import {useCreateUpdateForm} from "lib/hooks/useCreateUpdateForm" +import {useState} from "react" +import {textHelper} from "lib/utils" +import {appPermissions} from "lib/constants/app-permissions.config" +import {isEqual, sortBy, difference, pick} from "lodash" + +interface PermissionTableProps { + assignedPermissions?: string[] + onPermissionChange: (permissions: string[]) => void +} +const PermissionsTable = (props: PermissionTableProps) => { + const allPermissions = Object.keys(appPermissions) + const [assignedPermissions, setAssignedPermissions] = useState(props.assignedPermissions || []) + + const handlePermissionChange = (permission: string) => { + let updatedPermissions = [] + if (assignedPermissions.includes(permission)) { + updatedPermissions = assignedPermissions.filter((p) => p !== permission) + } else { + updatedPermissions = [...assignedPermissions, permission] + } + setAssignedPermissions(updatedPermissions) + props.onPermissionChange(updatedPermissions) + } + + return ( + + + + + + + {allPermissions.map((permission) => ( + + + + + ))} + +
    + Permissions +
    {textHelper.camelCaseToTitleCase(permission)} + handlePermissionChange(permission)} + > +
    +
    + ) +} + +export {CreateUpdateForm} +interface CreateUpdateFormProps { + user?: User + assignedPermissions?: string[] +} +function CreateUpdateForm({user, assignedPermissions}: CreateUpdateFormProps) { + let router = useRouter() + const formShape = { + Username: Yup.string().max(100).required("Username is required"), + FirstName: Yup.string().required("First Name is required"), + LastName: Yup.string().required("Last Name is required"), + Email: Yup.string().email("Email is invalid").required("Email is required"), + Phone: Yup.string(), + Active: Yup.boolean() + } + + const [permissions, setPermissions] = useState(assignedPermissions || []) + + const handlePermissionChange = (updatedPermissions: string[]) => { + setPermissions(updatedPermissions) + } + + const {successToast, validationSchema, initialValues, onSubmit} = useCreateUpdateForm( + user, + formShape, + createUser, + updateUser + ) + + async function createUser(fields: User) { + const createdUser = await AdminUsers.Create(fields) + const permissionsToAdd = permissions.map((permission) => + AdminUserGroups.SaveUserAssignment({UserGroupID: permission, UserID: createdUser.ID}) + ) + await Promise.all(permissionsToAdd) + successToast({ + description: "User created successfully." + }) + router.back() + } + + async function updateUser(fields: User) { + const formFields = Object.keys(formShape) + const updatedUser = await AdminUsers.Patch(fields.ID, pick(fields, formFields)) + const permissionsChanged = !isEqual(sortBy(assignedPermissions), sortBy(permissions)) + let successMessage = "User updated successfully." + if (permissionsChanged) { + const permissionsToAdd = difference(permissions, assignedPermissions).map((permission) => + AdminUserGroups.SaveUserAssignment({UserGroupID: permission, UserID: updatedUser.ID}) + ) + const permissionsToRemove = difference(assignedPermissions, permissions).map((permission) => + AdminUserGroups.DeleteUserAssignment(permission, updatedUser.ID) + ) + + await Promise.all([...permissionsToAdd, ...permissionsToRemove]) + successMessage += " Please note, user will need to log out and back in for permission changes to take effect." + } + successToast({ + description: successMessage + }) + router.back() + } + + return ( + + + + {({ + // most of the useful available Formik props + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + setFieldValue, + resetForm + }) => ( + + + + + + + + + + + + + + + + + )} + + + + ) +} diff --git a/src/lib/components/adminusers/index.tsx b/src/lib/components/adminusers/index.tsx new file mode 100644 index 00000000..24db4ac7 --- /dev/null +++ b/src/lib/components/adminusers/index.tsx @@ -0,0 +1 @@ +export * from "./CreateUpdateForm" diff --git a/src/lib/components/analytics/AverageOrderAmount.tsx b/src/lib/components/analytics/AverageOrderAmount.tsx new file mode 100644 index 00000000..57d90285 --- /dev/null +++ b/src/lib/components/analytics/AverageOrderAmount.tsx @@ -0,0 +1,107 @@ +import {Flex, Text, Box, useColorModeValue} from "@chakra-ui/react" +import React, {useEffect, useState} from "react" +import LineChart from "../charts/LineChart" +import Card from "../card/Card" +import {dashboardService} from "lib/api" + +export default function AverageOrderAmount() { + const boxBgColor = useColorModeValue("boxBgColor.100", "boxBgColor.600") + const color = useColorModeValue("boxTextColor.900", "boxTextColor.100") + const headingColor = useColorModeValue("boxTextColor.400", "boxTextColor.300") + const [totalSales, settotalSales] = useState([Number]) + const [totalPreviousYearSales, settotalPreviousYearSales] = useState([Number]) + //const [chartData, setchartData] = useState() + let chartData = require("../../mockdata/dashboard_data.json") + console.log(chartData) + useEffect(() => { + initData() + }, []) + + async function initData() { + if (process.env.NEXT_PUBLIC_OC_USELIVEDATA) { + //TODO COMPLETE THIS SECTION + //These functions will bring in real data + //const totalSales = await dashboardService.getTotalSalesByMonth() + //settotalSales(totalSales) + //const totalSalesPreviousYear = + // await dashboardService.getTotalSalesPreviousYearByMonth() + //settotalPreviousYearSales(totalSalesPreviousYear) + } else { + //This function will bring in mock data + //let data = require("../../mockdata/dashboard_data.json") + //setchartData(data) + } + } + const d = new Date() + let year = d.getFullYear() + const options = { + chart: { + height: "auto", + toolbar: { + show: true, + tools: { + download: true, + selection: true, + zoom: true, + zoomin: true, + zoomout: true, + pan: true, + reset: true + }, + autoSelected: "zoom" + }, + zoom: { + enabled: true, + type: "x", + zoomedArea: { + fill: { + color: "#90CAF9", + opacity: 0.4 + }, + stroke: { + color: "#0D47A1", + opacity: 0.4, + width: 1 + } + } + } + }, + xaxis: { + categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + }, + title: { + text: chartData.salesoverview.title, + align: "left" + } + } + const series = [ + { + name: chartData.salesoverview.series.currentyear.title, + data: chartData.salesoverview.series.currentyear.data + }, + { + name: chartData.salesoverview.series.previousyear.title, + data: chartData.salesoverview.series.previousyear.data + } + ] + + return ( + + + + {chartData.salesoverview.title} + + + + ({chartData.salesoverview.percentchangeindicator} + {chartData.salesoverview.percentchange}%) more + + in {year} + + + + + + + ) +} diff --git a/src/lib/components/analytics/PercentChangeTile.tsx b/src/lib/components/analytics/PercentChangeTile.tsx new file mode 100644 index 00000000..c62503ee --- /dev/null +++ b/src/lib/components/analytics/PercentChangeTile.tsx @@ -0,0 +1,50 @@ +import {Flex, Text, Box, Icon, useColorModeValue} from "@chakra-ui/react" +import React from "react" +import Card from "../card/Card" + +export default function PercentChangeTitle(prop) { + const color = useColorModeValue("textColor.900", "textColor.100") + const bgColor = useColorModeValue("boxBgColor.100", "boxBgColor.600") + const headingColor = useColorModeValue("boxTextColor.400", "boxTextColor.300") + return ( + + + + {prop.title} + + + {prop.totalamount} + + + {prop.percentchangetype === "pos" ? ( + + + {prop.percentchange}% + + ) : ( + + {prop.percentchange}% + + )} + {prop.percentlabel} + + + + + {prop.icon} + + + + ) +} diff --git a/src/lib/components/auth/ProtectedApp.tsx b/src/lib/components/auth/ProtectedApp.tsx new file mode 100644 index 00000000..e3d029f5 --- /dev/null +++ b/src/lib/components/auth/ProtectedApp.tsx @@ -0,0 +1,23 @@ +import {useAuth} from "lib/hooks/useAuth" +import {useRouter} from "next/router" + +/** + * This higher order component is used to ensure + * someone can't navigate to a page (other than login) without a valid ordercloud token + */ +export const ProtectedApp = ({children}: any) => { + const router = useRouter() + const {isAuthenticated} = useAuth() + const isLoginPage = () => router.pathname === "/" + + // can only use router on the browser + if (typeof window !== "undefined") { + if (!isAuthenticated && !isLoginPage()) { + router.push("/") + } else if (isAuthenticated && isLoginPage()) { + router.push("/dashboard") + } + } + + return children +} diff --git a/src/lib/components/auth/ProtectedContent.tsx b/src/lib/components/auth/ProtectedContent.tsx new file mode 100644 index 00000000..9b688ffb --- /dev/null +++ b/src/lib/components/auth/ProtectedContent.tsx @@ -0,0 +1,34 @@ +import {AuthContext} from "lib/context/auth-context" +import {useAuth} from "lib/hooks/useAuth" +import React, {PropsWithChildren, useEffect, useState} from "react" +import {AccessQualifier, isAllowedAccess} from "../../hooks/useHasAccess" + +interface ProtectedContentProps { + /** + * determines whether or not a user should be able to see the content + * accepts a single role, an array of roles (user should have at least one), or a function + */ + hasAccess: AccessQualifier + children: JSX.Element +} + +/**' + * This component should be used to hide content based on ordercloud roles + */ +const ProtectedContent = (props: ProtectedContentProps) => { + const {hasAccess, children} = props + const {assignedRoles} = useAuth() + const [canSee, setCanSee] = useState(false) + + useEffect(() => { + if (assignedRoles?.length && isAllowedAccess(assignedRoles, hasAccess)) { + setCanSee(true) + } else { + setCanSee(false) + } + }, [assignedRoles, hasAccess]) + + return canSee ? children : <> +} + +export default ProtectedContent diff --git a/src/lib/components/branding/BrandedBox.tsx b/src/lib/components/branding/BrandedBox.tsx new file mode 100644 index 00000000..346ebc73 --- /dev/null +++ b/src/lib/components/branding/BrandedBox.tsx @@ -0,0 +1,36 @@ +import {useColorMode, useColorModeValue, Box, Button, Tooltip} from "@chakra-ui/react" +import {FiMinus, FiPlus} from "react-icons/fi" + +interface BrandedBoxProperties { + mt?: number + mb?: number + children: JSX.Element + setExpanded?: (boolean) => void + isExpaned?: boolean +} + +export default function BrandedBox({children, mt, mb, isExpaned, setExpanded}: BrandedBoxProperties) { + const {colorMode, toggleColorMode} = useColorMode() + const bgColor = useColorModeValue("boxBgColor.100", "boxBgColor.600") + const shadow = "5px 5px 5px #999999" + const color = useColorModeValue("boxTextColor.900", "boxTextColor.100") + + return ( + + + + + {children} + + ) +} diff --git a/src/lib/components/branding/BrandedSpinner.tsx b/src/lib/components/branding/BrandedSpinner.tsx new file mode 100644 index 00000000..e865b75d --- /dev/null +++ b/src/lib/components/branding/BrandedSpinner.tsx @@ -0,0 +1,7 @@ +import {Spinner, useColorModeValue} from "@chakra-ui/react" + +export default function BrandedSpinner() { + const spinnerColor = useColorModeValue("brand.200", "brand.600") + + return +} diff --git a/src/lib/components/branding/BrandedTable.tsx b/src/lib/components/branding/BrandedTable.tsx new file mode 100644 index 00000000..2e48569c --- /dev/null +++ b/src/lib/components/branding/BrandedTable.tsx @@ -0,0 +1,20 @@ +import {useColorMode, useColorModeValue, Box, TableContainer, Table} from "@chakra-ui/react" + +export default function BrandedTable({children}) { + const tableHeaderBg = useColorModeValue("white.000", "gray.900") + const tableBg = useColorModeValue("brand.300", "brand.500") + const tableColor = useColorModeValue("textColor.900", "textColor.100") + const tableBorder = useColorModeValue("gray.400", "gray.400") + + return ( + + {children}
    +
    + ) +} diff --git a/src/lib/components/branding/FooterLogo.tsx b/src/lib/components/branding/FooterLogo.tsx new file mode 100644 index 00000000..f8098911 --- /dev/null +++ b/src/lib/components/branding/FooterLogo.tsx @@ -0,0 +1,17 @@ +import {Image, useColorMode} from "@chakra-ui/react" +import {Link} from "../navigation/Link" + +const FooterLogo = () => { + const {colorMode} = useColorMode() + return ( + + {colorMode === "dark" ? ( + Sitecore + ) : ( + Sitecore + )} + + ) +} + +export default FooterLogo diff --git a/src/lib/components/branding/HeaderLogo.tsx b/src/lib/components/branding/HeaderLogo.tsx new file mode 100644 index 00000000..d898c20d --- /dev/null +++ b/src/lib/components/branding/HeaderLogo.tsx @@ -0,0 +1,19 @@ +import {Image, HStack, useColorMode} from "@chakra-ui/react" +import {Link} from "../navigation/Link" + +const HeaderLogo = () => { + const {colorMode} = useColorMode() + return ( + + + {colorMode === "dark" ? ( + Sitecore + ) : ( + Sitecore + )} + + + ) +} + +export default HeaderLogo diff --git a/src/lib/components/buyers/BuyerContextSwitch.tsx b/src/lib/components/buyers/BuyerContextSwitch.tsx new file mode 100644 index 00000000..fc3c61f2 --- /dev/null +++ b/src/lib/components/buyers/BuyerContextSwitch.tsx @@ -0,0 +1,138 @@ +import { + Avatar, + Box, + Button, + ButtonGroup, + Flex, + HStack, + Image, + Menu, + MenuButton, + MenuItem, + MenuList, + Spacer, + Text, + VStack +} from "@chakra-ui/react" +import {buyersService, catalogsService, userGroupsService, usersService} from "lib/api" +import {useEffect, useState} from "react" + +import {Buyer} from "ordercloud-javascript-sdk" +import {ChevronDownIcon} from "@chakra-ui/icons" +import {useRouter} from "next/router" + +export default function BuyerContextSwitch({...props}) { + const [currentBuyer, setCurrentBuyer] = useState({} as Buyer) + const [buyers, setBuyers] = useState([]) + const [buyersMeta, setBuyersMeta] = useState({}) + const router = useRouter() + const buyerid = router.query.buyerid.toString() + + useEffect(() => { + initBuyersData() + }, []) + + useEffect(() => { + if (buyers.length > 0 && buyerid) { + const _currentBuyer = buyers.find((buyer) => buyer.ID === buyerid) + setCurrentBuyer(_currentBuyer) + } + }, [buyerid, buyers]) + + async function initBuyersData() { + let _buyerListMeta = {} + const buyersList = await buyersService.list() + setBuyers(buyersList.Items) + const requests = buyersList.Items.map(async (buyer) => { + _buyerListMeta[buyer.ID] = {} + _buyerListMeta[buyer.ID]["userGroupsCount"] = await userGroupsService.getUserGroupsCountByBuyerID(buyer.ID) + _buyerListMeta[buyer.ID]["usersCount"] = await usersService.getUsersCountByBuyerID(buyer.ID) + _buyerListMeta[buyer.ID]["catalogsCount"] = await catalogsService.getCatalogsCountByBuyerID(buyer.ID) + }) + await Promise.all(requests) + setBuyersMeta(_buyerListMeta) + } + + return ( + <> + + + + + + + {currentBuyer?.Name} + + + {currentBuyer?.ID} + + + + {buyers.length > 1 && ( + + } size="lg" ml="30px"> + {currentBuyer?.Name} + + + {buyers.map((buyer, index) => ( + <> + router.push({query: {buyerid: buyer.ID}})}> + {buyer.Name} + {buyer.Name} + + + ))} + + + )} + + + + + + + + + + + + + ) +} diff --git a/src/lib/components/buyers/CreateUpdateForm.tsx b/src/lib/components/buyers/CreateUpdateForm.tsx new file mode 100644 index 00000000..0ddc3373 --- /dev/null +++ b/src/lib/components/buyers/CreateUpdateForm.tsx @@ -0,0 +1,117 @@ +import * as Yup from "yup" +import {Box, Button, ButtonGroup, Flex, Stack} from "@chakra-ui/react" +import {InputControl, NumberInputControl, PercentComplete, SelectControl, SwitchControl} from "formik-chakra-ui" +import {Buyer, Catalog} from "ordercloud-javascript-sdk" +import Card from "../card/Card" +import {Formik} from "formik" +import {buyersService, catalogsService} from "../../api" +import {useRouter} from "next/router" +import {useCreateUpdateForm} from "lib/hooks/useCreateUpdateForm" +import {useEffect, useState} from "react" + +export {CreateUpdateForm} + +interface CreateUpdateFormProps { + buyer?: Buyer +} + +function CreateUpdateForm({buyer}: CreateUpdateFormProps) { + const router = useRouter() + const formShape = { + Name: Yup.string().required("Name is required"), + xp_MarkupPercent: Yup.number() + } + const {isCreating, successToast, errorToast, validationSchema, initialValues, onSubmit} = useCreateUpdateForm( + buyer, + formShape, + createBuyer, + updateBuyer + ) + const [catalogs, setCatalogs] = useState([] as Catalog[]) + + useEffect(() => { + initCatalogsData() + }, []) + + async function initCatalogsData() { + const response = await catalogsService.list() + setCatalogs(response.Items) + } + + async function createBuyer(fields: Buyer) { + await buyersService.create(fields) + successToast({ + description: "Buyer created successfully." + }) + router.push(".") + } + + async function updateBuyer(fields: Buyer) { + await buyersService.update(fields) + successToast({ + description: "Buyer updated successfully." + }) + router.push(".") + } + + return ( + + + + {({ + // most of the useful available Formik props + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + setFieldValue, + resetForm + }) => ( + + + + + + {catalogs.map((catalog) => ( + + ))} + + + + + {isCreating ? : } + + + + + + + + )} + + + + ) +} diff --git a/src/lib/components/buyers/index.tsx b/src/lib/components/buyers/index.tsx new file mode 100644 index 00000000..32be98a4 --- /dev/null +++ b/src/lib/components/buyers/index.tsx @@ -0,0 +1,2 @@ +export * from "./CreateUpdateForm" +export * from "./BuyerContextSwitch" diff --git a/src/lib/components/card/AddressCard.tsx b/src/lib/components/card/AddressCard.tsx new file mode 100644 index 00000000..e11a88d1 --- /dev/null +++ b/src/lib/components/card/AddressCard.tsx @@ -0,0 +1,28 @@ +import {Text, Flex, useStyleConfig, Input, HStack, VStack} from "@chakra-ui/react" + +export default function AddressCard(props) { + return ( + + + Address + + Address 2 + + + + City + + + + State + + + + Postal Code + + + + + + ) +} diff --git a/src/lib/components/card/Card.tsx b/src/lib/components/card/Card.tsx new file mode 100644 index 00000000..9cc2fc1f --- /dev/null +++ b/src/lib/components/card/Card.tsx @@ -0,0 +1,66 @@ +import {Box, Flex, Text, IconButton, useStyleConfig} from "@chakra-ui/react" + +import {useEffect, useState} from "react" +import {HiOutlineMinusSm, HiOutlinePlusSm} from "react-icons/hi" +function Card(props) { + const {variant, closedText, children, ...rest} = props + const styles = useStyleConfig("Card", {variant}) + const [isShownPanel, setIsShownPanel] = useState(true) + const [isShownButton, setIsShownButton] = useState(false) + const inClosedText = closedText ?? "Panel is closed" + + useEffect(() => { + if (props.showclosebutton !== undefined) { + if (props.showclosebutton === "false") { + setIsShownButton(true) + } + } + }, [props.showclosebutton]) + + const handlePanelClick = (event) => { + // toggle shown state + setIsShownPanel((current) => !current) + } + + return ( + + : } + onClick={handlePanelClick} + hidden={isShownButton} + > + {isShownPanel && ( + + {children} + + )} + {isShownPanel == false && ( + + + {inClosedText} + + + )} + + ) +} + +export default Card diff --git a/src/lib/components/card/CardBody.tsx b/src/lib/components/card/CardBody.tsx new file mode 100644 index 00000000..290d1c89 --- /dev/null +++ b/src/lib/components/card/CardBody.tsx @@ -0,0 +1,12 @@ +import {Box, useStyleConfig} from "@chakra-ui/react" +function CardBody(props) { + const {variant, children, ...rest} = props + const styles = useStyleConfig("CardBody", {variant}) + return ( + + {children} + + ) +} + +export default CardBody diff --git a/src/lib/components/card/CardFooter.tsx b/src/lib/components/card/CardFooter.tsx new file mode 100644 index 00000000..55470d19 --- /dev/null +++ b/src/lib/components/card/CardFooter.tsx @@ -0,0 +1,12 @@ +import {Box, useStyleConfig} from "@chakra-ui/react" +function CardFooter(props) { + const {variant, children, ...rest} = props + const styles = useStyleConfig("CardFooter", {variant}) + return ( + + {children} + + ) +} + +export default CardFooter diff --git a/src/lib/components/card/CardHeader.tsx b/src/lib/components/card/CardHeader.tsx new file mode 100644 index 00000000..d1045fc7 --- /dev/null +++ b/src/lib/components/card/CardHeader.tsx @@ -0,0 +1,12 @@ +import {Box, useStyleConfig} from "@chakra-ui/react" +function CardHeader(props) { + const {variant, children, ...rest} = props + const styles = useStyleConfig("CardHeader", {variant}) + return ( + + {children} + + ) +} + +export default CardHeader diff --git a/src/lib/components/card/LettersCard.tsx b/src/lib/components/card/LettersCard.tsx new file mode 100644 index 00000000..53a1dad4 --- /dev/null +++ b/src/lib/components/card/LettersCard.tsx @@ -0,0 +1,32 @@ +import {Badge, Box, HStack, Text} from "@chakra-ui/react" + +export default function LettersCard(props) { + var str = new String(props.FirstName) + var strlast = new String(props.LastName) + const firstnameletter = str.charAt(0) + const lastnameletter = strlast.charAt(0) + return ( + + + + {firstnameletter} + {lastnameletter} + + + + ) +} diff --git a/src/lib/components/catalogs/CreateUpdateForm.tsx b/src/lib/components/catalogs/CreateUpdateForm.tsx new file mode 100644 index 00000000..8dac018e --- /dev/null +++ b/src/lib/components/catalogs/CreateUpdateForm.tsx @@ -0,0 +1,93 @@ +import * as Yup from "yup" +import {Box, Button, ButtonGroup, Flex, Stack} from "@chakra-ui/react" +import {InputControl, SwitchControl, TextareaControl} from "formik-chakra-ui" +import Card from "../card/Card" +import {Catalog} from "ordercloud-javascript-sdk" +import {Formik} from "formik" +import {catalogsService} from "lib/api" +import {useRouter} from "next/router" +import {useCreateUpdateForm} from "lib/hooks/useCreateUpdateForm" + +export {CreateUpdateForm} + +interface CreateUpdateFormProps { + catalog?: Catalog +} +function CreateUpdateForm({catalog}: CreateUpdateFormProps) { + const router = useRouter() + const formShape = { + Name: Yup.string().max(100).required("Name is required"), + Description: Yup.string().max(100) + } + const {isCreating, successToast, errorToast, validationSchema, initialValues, onSubmit} = + useCreateUpdateForm(catalog, formShape, createCatalog, updateCatalog) + + async function createCatalog(fields: Catalog) { + const createdCatalog = await catalogsService.create(fields) + await catalogsService.saveAssignment(router.query.buyerid, createdCatalog.ID) + successToast({ + description: "Catalog created successfully." + }) + router.push(`/buyers/${router.query.buyerid}/catalogs`) + } + + async function updateCatalog(fields: Catalog) { + const updatedCatalog = await catalogsService.update(fields) + await catalogsService.saveAssignment(router.query.buyerid, updatedCatalog.ID) + successToast({ + description: "Catalog updated successfully." + }) + router.push(`/buyers/${router.query.buyerid}/catalogs`) + } + + return ( + + + + {({ + // most of the useful available Formik props + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + setFieldValue, + resetForm + }) => ( + + + + + + + + + + + + + )} + + + + ) +} diff --git a/src/lib/components/catalogs/index.tsx b/src/lib/components/catalogs/index.tsx new file mode 100644 index 00000000..24db4ac7 --- /dev/null +++ b/src/lib/components/catalogs/index.tsx @@ -0,0 +1 @@ +export * from "./CreateUpdateForm" diff --git a/src/lib/components/categories/CreateUpdateForm.tsx b/src/lib/components/categories/CreateUpdateForm.tsx new file mode 100644 index 00000000..49eeccf7 --- /dev/null +++ b/src/lib/components/categories/CreateUpdateForm.tsx @@ -0,0 +1,155 @@ +import * as Yup from "yup" +import {Box, Button, ButtonGroup, Flex, HStack, Stack} from "@chakra-ui/react" +import {Category} from "ordercloud-javascript-sdk" +import {InputControl, SwitchControl, TextareaControl} from "formik-chakra-ui" +import {Formik} from "formik" +import {categoriesService} from "lib/api" +import {useRouter} from "next/router" +import {useCreateUpdateForm} from "lib/hooks/useCreateUpdateForm" + +export {CreateUpdateForm} + +interface CreateUpdateFormProps { + category?: Category + headerComponent?: React.ReactNode + parentId?: string + onSuccess?: (category: Category) => void +} +function CreateUpdateForm({category, headerComponent, parentId, onSuccess}: CreateUpdateFormProps) { + const router = useRouter() + const formShape = { + Name: Yup.string().max(100).required("Name is required"), + Description: Yup.string().max(100) + } + const {isCreating, successToast, errorToast, validationSchema, initialValues, onSubmit} = + useCreateUpdateForm(category, formShape, createCategory, updateCategory) + + async function createCategory(fields: Category) { + fields.ParentID = parentId + const createdCatalog = await categoriesService.create(router.query.catalogid, fields) + await categoriesService.saveAssignment( + router.query.catalogid, + createdCatalog.ID, + router.query.buyerid, + router.query.usergroupid + ) + successToast({ + description: "Category created successfully." + }) + router.push(`/buyers/${router.query.buyerid}/catalogs/${router.query.catalogid}/categories`) + } + + async function updateCategory(fields: Category) { + const updatedCatalog = await categoriesService.update(router.query.catalogid, fields) + await categoriesService.saveAssignment( + router.query.catalogid, + updatedCatalog.ID, + router.query.buyerid, + router.query.usergroupid + ) + successToast({ + description: "Category updated successfully." + }) + router.push(`/buyers/${router.query.buyerid}/catalogs/${router.query.catalogid}/categories`) + } + + async function deleteCategory(categoryid) { + try { + await categoriesService.delete(router.query.catalogid, categoryid) + successToast({ + description: "Category deleted successfully." + }) + } catch (e) { + errorToast({ + description: "Category delete failed" + }) + } + if (onSuccess) { + onSuccess(category) + } + } + + return ( + + {headerComponent} + + + {({ + // most of the useful available Formik props + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + setFieldValue, + resetForm + }) => ( + + + + + + + + + + + + + {!isCreating && ( + + )} + + + + + )} + + + + ) +} diff --git a/src/lib/components/categories/index.tsx b/src/lib/components/categories/index.tsx new file mode 100644 index 00000000..24db4ac7 --- /dev/null +++ b/src/lib/components/categories/index.tsx @@ -0,0 +1 @@ +export * from "./CreateUpdateForm" diff --git a/src/lib/components/charts/BarChart.tsx b/src/lib/components/charts/BarChart.tsx new file mode 100644 index 00000000..90cb2197 --- /dev/null +++ b/src/lib/components/charts/BarChart.tsx @@ -0,0 +1,7 @@ +import React, {Component} from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function BarChart(props) { + return +} diff --git a/src/lib/components/charts/BubbleChart.tsx b/src/lib/components/charts/BubbleChart.tsx new file mode 100644 index 00000000..0336aef9 --- /dev/null +++ b/src/lib/components/charts/BubbleChart.tsx @@ -0,0 +1,9 @@ +import React, {Component} from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function BubbleChart(props) { + return ( + + ) +} diff --git a/src/lib/components/charts/DonutChart.tsx b/src/lib/components/charts/DonutChart.tsx new file mode 100644 index 00000000..3672ad13 --- /dev/null +++ b/src/lib/components/charts/DonutChart.tsx @@ -0,0 +1,9 @@ +import React from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function DonutChart(props) { + return ( + + ) +} diff --git a/src/lib/components/charts/LineBarChart.tsx b/src/lib/components/charts/LineBarChart.tsx new file mode 100644 index 00000000..fccce3ef --- /dev/null +++ b/src/lib/components/charts/LineBarChart.tsx @@ -0,0 +1,7 @@ +import React, {Component} from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function DonutChart(props) { + return +} diff --git a/src/lib/components/charts/LineChart.tsx b/src/lib/components/charts/LineChart.tsx new file mode 100644 index 00000000..cbddcf76 --- /dev/null +++ b/src/lib/components/charts/LineChart.tsx @@ -0,0 +1,7 @@ +import React from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function LineChart(props) { + return +} diff --git a/src/lib/components/charts/PieChart.tsx b/src/lib/components/charts/PieChart.tsx new file mode 100644 index 00000000..87773ccc --- /dev/null +++ b/src/lib/components/charts/PieChart.tsx @@ -0,0 +1,7 @@ +import React from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function PieChart(props) { + return +} diff --git a/src/lib/components/charts/PolarChart.tsx b/src/lib/components/charts/PolarChart.tsx new file mode 100644 index 00000000..f02f6bde --- /dev/null +++ b/src/lib/components/charts/PolarChart.tsx @@ -0,0 +1,9 @@ +import React from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function PolarChart(props) { + return ( + + ) +} diff --git a/src/lib/components/charts/RadarChart.tsx b/src/lib/components/charts/RadarChart.tsx new file mode 100644 index 00000000..da8efb8b --- /dev/null +++ b/src/lib/components/charts/RadarChart.tsx @@ -0,0 +1,9 @@ +import React from "react" +import dynamic from "next/dynamic" +const ReactApexChart = dynamic(() => import("react-apexcharts"), {ssr: false}) + +export default function RadarChart(props) { + return ( + + ) +} diff --git a/src/lib/components/data-table/DataTable.tsx b/src/lib/components/data-table/DataTable.tsx new file mode 100644 index 00000000..89ad80e9 --- /dev/null +++ b/src/lib/components/data-table/DataTable.tsx @@ -0,0 +1,23 @@ +import {OrderCloudTable, OrderCloudTableFilters} from "../ordercloud-table" +import {ReactTable} from "../react-table/ReactTable" + +interface DataTableProps { + columns + data + filters?: OrderCloudTableFilters + fetchData?: (filters: OrderCloudTableFilters) => Promise +} +/** + * ReactTable is a table component with clientside pagination/search suitable for non-ordercloud data + * OrderCloudTable is a table component that handles server side pagination/search and is built with ordercloud api in mind + * + * This high level component is used as a common interface for the two so that implementors can easily use one or the other + * with greater ease. If you need a quick demo and don't care if data is filtered server side then simply pass in columns and data + * otherwise pass in columns, data, filters, and fetchData + */ +export function DataTable(props: DataTableProps) { + if (props.fetchData && props.filters) { + return + } + return +} diff --git a/src/lib/components/datepicker/DatePicker.tsx b/src/lib/components/datepicker/DatePicker.tsx new file mode 100644 index 00000000..499487a3 --- /dev/null +++ b/src/lib/components/datepicker/DatePicker.tsx @@ -0,0 +1,43 @@ +import "react-datepicker/dist/react-datepicker.css" + +import {Input, InputGroup, InputRightElement} from "@chakra-ui/react" +import React, {HTMLAttributes, forwardRef} from "react" +import ReactDatePicker, {ReactDatePickerProps} from "react-datepicker" + +import {CalendarIcon} from "@chakra-ui/icons" + +const customDateInput = ({value, onClick, onChange}: any, ref: any) => ( + +) +customDateInput.displayName = "DateInput" + +const CustomInput = forwardRef(customDateInput) + +interface Props { + isClearable?: boolean + onChange: (date: Date) => any + selectedDate: Date | undefined + showPopperArrow?: boolean +} + +const DatePicker = ({selectedDate, onChange, ...props}: Props) => { + return ( + <> + + } + dateFormat="MM/dd/yyyy" + {...props} + /> + + + + + + ) +} + +export default DatePicker diff --git a/src/lib/components/dndtreeview/CustomDragPreview.module.css b/src/lib/components/dndtreeview/CustomDragPreview.module.css new file mode 100644 index 00000000..1334db50 --- /dev/null +++ b/src/lib/components/dndtreeview/CustomDragPreview.module.css @@ -0,0 +1,19 @@ +.root { + align-items: "center"; + background-color: #1967d2; + border-radius: 4px; + box-shadow: 0 12px 24px -6px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.08); + color: #fff; + display: inline-grid; + font-size: 14px; + gap: 8px; + grid-template-columns: auto auto; + padding: 4px 8px; + pointer-events: none; +} + +.icon, +.label { + align-items: center; + display: flex; +} diff --git a/src/lib/components/dndtreeview/CustomDragPreview.tsx b/src/lib/components/dndtreeview/CustomDragPreview.tsx new file mode 100644 index 00000000..50bc2f72 --- /dev/null +++ b/src/lib/components/dndtreeview/CustomDragPreview.tsx @@ -0,0 +1,21 @@ +import {NodeModel} from "@minoru/react-dnd-treeview" +import React from "react" +import {TypeIcon} from "./TypeIcon" +import styles from "./CustomDragPreview.module.css" + +type Props = { + monitorProps: any +} + +export const CustomDragPreview: React.FC = (props) => { + const item = props.monitorProps.item + + return ( +
    +
    + +
    +
    {item.text}
    +
    + ) +} diff --git a/src/lib/components/dndtreeview/CustomNode.module.css b/src/lib/components/dndtreeview/CustomNode.module.css new file mode 100644 index 00000000..30cb7de4 --- /dev/null +++ b/src/lib/components/dndtreeview/CustomNode.module.css @@ -0,0 +1,30 @@ +.root { + align-items: center; + display: grid; + grid-template-columns: auto auto 1fr auto; + padding-inline-end: 8px; +} + +.root.isSelected { + color: #1967d2; +} + +.expandIconWrapper { + align-items: center; + font-size: 0; + cursor: pointer; + display: flex; + height: 24px; + justify-content: center; + width: 24px; + transition: transform linear 0.1s; + transform: rotate(0deg); +} + +.expandIconWrapper.isOpen { + transform: rotate(90deg); +} + +.labelGridItem { + padding-inline-start: 8px; +} diff --git a/src/lib/components/dndtreeview/CustomNode.tsx b/src/lib/components/dndtreeview/CustomNode.tsx new file mode 100644 index 00000000..556112c9 --- /dev/null +++ b/src/lib/components/dndtreeview/CustomNode.tsx @@ -0,0 +1,64 @@ +import {NodeModel, ocNodeModel} from "@minoru/react-dnd-treeview" + +import {ChevronRightIcon} from "@chakra-ui/icons" +import React from "react" +import {Button, Icon, Text} from "@chakra-ui/react" +import {TypeIcon} from "./TypeIcon" +import styles from "./CustomNode.module.css" +import {HiOutlinePlusCircle} from "react-icons/hi" +import {Category} from "ordercloud-javascript-sdk" + +declare module "@minoru/react-dnd-treeview" { + export interface ocNodeModel extends NodeModel { + type: string + } +} + +type Props = { + node: ocNodeModel + depth: number + isOpen: boolean + isSelected: boolean + onToggle: (id: ocNodeModel["id"]) => void + onSelect: (node: ocNodeModel) => void + onCategoryCreate: (category?: Category) => void +} + +export const CustomNode: React.FC = (props) => { + const {droppable, data} = props.node + const indent = props.depth * 24 + + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation() + props.onToggle(props.node.id) + } + + const handleSelect = () => props.onSelect(props.node) + + const categoryNode = ( +
    +
    + {props.node.droppable && props.node.data.ChildCount > 0 && ( +
    + +
    + )} +
    +
    + +
    +
    + {props.node.text} +
    + props.onCategoryCreate(props.node.data)} /> +
    + ) + + const createRootCategoryNode = ( + + ) + + return props.node.type === "category" ? categoryNode : createRootCategoryNode +} diff --git a/src/lib/components/dndtreeview/Placeholder.module.css b/src/lib/components/dndtreeview/Placeholder.module.css new file mode 100644 index 00000000..04f5f972 --- /dev/null +++ b/src/lib/components/dndtreeview/Placeholder.module.css @@ -0,0 +1,8 @@ +.root { + background-color: #1967d2; + height: 2px; + position: absolute; + right: 0; + transform: translateY(-50%); + top: 0; +} diff --git a/src/lib/components/dndtreeview/Placeholder.tsx b/src/lib/components/dndtreeview/Placeholder.tsx new file mode 100644 index 00000000..75291cec --- /dev/null +++ b/src/lib/components/dndtreeview/Placeholder.tsx @@ -0,0 +1,12 @@ +import React from "react" +import styles from "./Placeholder.module.css" + +type Props = { + depth: number +} + +export const Placeholder: React.FC = (props) => { + const left = props.depth * 24 + + return
    +} diff --git a/src/lib/components/dndtreeview/TreeView.module.css b/src/lib/components/dndtreeview/TreeView.module.css new file mode 100644 index 00000000..ce2fd6c8 --- /dev/null +++ b/src/lib/components/dndtreeview/TreeView.module.css @@ -0,0 +1,19 @@ +.app { + height: 100%; +} + +.treeRoot { + height: 100%; +} + +.draggingSource { + opacity: 0.3; +} + +.placeholderContainer { + position: relative; +} + +.dropTarget { + background-color: #e8f0fe; +} diff --git a/src/lib/components/dndtreeview/TreeView.tsx b/src/lib/components/dndtreeview/TreeView.tsx new file mode 100644 index 00000000..6c364a79 --- /dev/null +++ b/src/lib/components/dndtreeview/TreeView.tsx @@ -0,0 +1,65 @@ +import React, {useEffect, useState} from "react" +import {Tree, ocNodeModel} from "@minoru/react-dnd-treeview" +import {CustomDragPreview} from "./CustomDragPreview" +import {CustomNode} from "./CustomNode" +import {DndProvider} from "react-dnd" +import {HTML5Backend} from "react-dnd-html5-backend" +import {Placeholder} from "./Placeholder" +import styles from "./TreeView.module.css" + +function TreeView(props) { + const [treeData, setTreeData] = useState([]) + useEffect(() => { + setTreeData(props.treeData) + }, [props.treeData]) + + const handleDrop = (newTree: ocNodeModel[]) => setTreeData(newTree) + + return ( +
    +
    +

    + Current node:{" "} + {props.selectedNode ? props.selectedNode.text : "none"} +

    +
    + + + ( + + )} + dragPreviewRender={(monitorProps) => } + onDrop={handleDrop} + classes={{ + root: styles.treeRoot, + draggingSource: styles.draggingSource, + dropTarget: styles.dropTarget, + placeholder: styles.placeholderContainer + }} + sort={false} + insertDroppableFirst={false} + canDrop={(tree, {dragSource, dropTargetId, dropTarget}) => { + if (dragSource?.parent === dropTargetId) { + return true + } + }} + dropTargetOffset={10} + placeholderRender={(node, {depth}) => } + /> + +
    + ) +} + +export default TreeView diff --git a/src/lib/components/dndtreeview/TypeIcon.tsx b/src/lib/components/dndtreeview/TypeIcon.tsx new file mode 100644 index 00000000..b3e117a0 --- /dev/null +++ b/src/lib/components/dndtreeview/TypeIcon.tsx @@ -0,0 +1,28 @@ +import {MdDomain, MdGroup, MdLibraryBooks, MdPerson, MdStorefront} from "react-icons/md" + +import {Icon} from "@chakra-ui/react" +import React from "react" + +type Props = { + droppable: boolean + type?: any +} + +// The dnd-tree-view is used mainly to render categories hierarchy. +// TODO: In the mear future I will use it be able to provide the business user a way to explore the commerce byers organization with a tree view of buyers > Users groups > Users. +export const TypeIcon: React.FC = (props) => { + switch (props.type) { + case "buyer": + return + case "usergroup": + return + case "user": + return + case "catalog": + return + case "category": + return + default: + return + } +} diff --git a/src/lib/components/generic/ItemContent.tsx b/src/lib/components/generic/ItemContent.tsx new file mode 100644 index 00000000..39952fb1 --- /dev/null +++ b/src/lib/components/generic/ItemContent.tsx @@ -0,0 +1,29 @@ +import {Avatar, Flex, Icon, Text, useColorModeValue} from "@chakra-ui/react" +import React from "react" +import {HiOutlineClock} from "react-icons/hi" + +export function ItemContent(props) { + const navbarIcon = useColorModeValue("gray.500", "gray.200") + const notificationColor = useColorModeValue("gray.700", "white") + const spacing = " " + return ( + <> + + + + + {props.boldInfo} + {spacing} + + {props.info} + + + + + {props.time} + + + + + ) +} diff --git a/src/lib/components/generic/tagContainer.tsx b/src/lib/components/generic/tagContainer.tsx new file mode 100644 index 00000000..d40a1009 --- /dev/null +++ b/src/lib/components/generic/tagContainer.tsx @@ -0,0 +1,61 @@ +import {Box, Text, HStack, Icon} from "@chakra-ui/react" +import {HiOutlineX} from "react-icons/hi" + +export default function TagContainer(props) { + return ( + + + {Object.keys(props.tags).map((name, key) => { + return true && typeof props.tags[name] != "object" ? ( + +
    + { + <> + + {props.isEditing ? ( + + ) : ( + <> + )} + { + props.isEditing ? props.onNameClicked(name) : "" + }} + > + {name.replace("###", "")} + + + + } +
    +
    + ) : ( + <> + ) + })} +
    +
    + ) +} diff --git a/src/lib/components/icons/IconBox.tsx b/src/lib/components/icons/IconBox.tsx new file mode 100644 index 00000000..5703565c --- /dev/null +++ b/src/lib/components/icons/IconBox.tsx @@ -0,0 +1,12 @@ +import React from "react" +import {Flex} from "@chakra-ui/react" + +export default function IconBox(props) { + const {children, ...rest} = props + + return ( + + {children} + + ) +} diff --git a/src/lib/components/icons/Icons.tsx b/src/lib/components/icons/Icons.tsx new file mode 100644 index 00000000..5255c81e --- /dev/null +++ b/src/lib/components/icons/Icons.tsx @@ -0,0 +1,632 @@ +/* eslint-disable react/no-unknown-property */ +import {createIcon} from "@chakra-ui/icons" + +export const AdobexdLogo = createIcon({ + displayName: "AdobexdLogo", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const AtlassianLogo = createIcon({ + displayName: "AtlassianLogo", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const CartIcon = createIcon({ + displayName: "CartIcon", + viewBox: "0 0 24 24", + path: ( + + ) +}) + +export const ClockIcon = createIcon({ + displayName: "ClockIcon", + viewBox: "0 0 24 24", + path: ( + + + + + + + + + + + + + ) +}) +export const CreativeTimLogo = createIcon({ + displayName: "CreativeTimLogo", + viewBox: "0 0 100 100", + path: ( + + + + + + + + + + + ) + + // + // +}) + +export const CreditIcon = createIcon({ + displayName: "CreditIcon", + viewBox: "0 0 24 24", + path: ( + + ) +}) + +export const DashboardLogo = createIcon({ + displayName: "DashboardLogo", + viewBox: "0 0 1000 257", + path: ( + + + + + + + + + + + + + + + + + + + + + + ) +}) + +export const DashboardLogoWhite = createIcon({ + displayName: "DashboardLogo", + viewBox: "0 0 163.5 42", + path: ( + + + + + ) +}) + +export const DocumentIcon = createIcon({ + displayName: "DocumentIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const GlobeIcon = createIcon({ + displayName: "GlobeIcon", + viewBox: "0 0 24 24", + path: ( + + + + + + + + ) +}) + +export const HelpIcon = createIcon({ + displayName: "HelpIcon", + viewBox: "0 0 24 24", + path: ( + + ) +}) + +export const HomeIcon = createIcon({ + displayName: "HomeIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const InvisionLogo = createIcon({ + displayName: "InvisionLogo", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const JiraLogo = createIcon({ + displayName: "JiraLogo", + viewBox: "0 0 24 24", + path: ( + + + + + + ) +}) + +export const MastercardIcon = createIcon({ + displayName: "MastercardIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const PayPalIcon = createIcon({ + displayName: "PayPalIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const PersonIcon = createIcon({ + displayName: "PersonIcon", + viewBox: "0 0 24 24", + path: ( + + ) +}) + +export const ProfileIcon = createIcon({ + displayName: "ProfileIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const RocketIcon = createIcon({ + displayName: "RocketIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const SettingsIcon = createIcon({ + displayName: "SettingsIcon", + viewBox: "0 0 24 24", + // path can also be an array of elements, if you have multiple paths, lines, shapes, etc. + path: ( + + + + + ) +}) + +export const SlackLogo = createIcon({ + displayName: "SlackLogo", + viewBox: "0 0 24 24", + path: ( + + + + + + + ) +}) + +export const SpotifyLogo = createIcon({ + displayName: "SpotifyLogo", + viewBox: "0 0 24 24", + path: ( + + + + ) +}) + +export const SupportIcon = createIcon({ + // Doesn't display the full icon without w and h being specified + displayName: "BuildIcon", + viewBox: "0 0 24 24", + path: ( + + ) +}) + +export const StatsIcon = createIcon({ + displayName: "StatsIcon", + viewBox: "0 0 24 24", + path: ( + + ) +}) + +export const WalletIcon = createIcon({ + displayName: "WalletIcon", + viewBox: "0 0 24 24", + path: ( + + + + + ) +}) + +export const VisaIcon = createIcon({ + displayName: "VisaIcon", + viewBox: "0 0 24 24", + path: ( + + + + ) +}) + +export const ArgonLogoDark = createIcon({ + displayName: "ArgonLogoDark", + viewBox: "0 0 74 27", + path: ( + + + + + ) +}) + +export const ArgonLogoLight = createIcon({ + displayName: "ArgonLogoLight", + viewBox: "0 0 74 27", + path: ( + + + + + ) +}) + +export const ChakraLogoDark = createIcon({ + displayName: "ChakraLogoDark", + viewBox: "0 0 82 21", + path: ( + + + + + + + + + + + + ) +}) + +export const ChakraLogoLight = createIcon({ + displayName: "ChakraLogoLight", + viewBox: "0 0 82 21", + path: ( + + + + + ) +}) + +export const ChakraLogoBlue = createIcon({ + displayName: "ChakraLogoBlue", + viewBox: "0 0 82 21", + path: ( + + + + + ) +}) + +export const BitcoinLogo = createIcon({ + displayName: "BitcoinLogo", + viewBox: "0 0 67 14", + path: ( + + + + + ) +}) + +export const ArgonLogoMinifiedDark = createIcon({ + displayName: "ArgonLogoMinifiedDark", + viewBox: "0 0 36 36", + path: ( + + + + ) +}) + +export const ArgonLogoMinifiedLight = createIcon({ + displayName: "ArgonLogoMinifiedLight", + viewBox: "0 0 36 36", + path: ( + + + + ) +}) diff --git a/src/lib/components/motion/Box.tsx b/src/lib/components/motion/Box.tsx new file mode 100644 index 00000000..15d74826 --- /dev/null +++ b/src/lib/components/motion/Box.tsx @@ -0,0 +1,12 @@ +import type {HTMLChakraProps} from "@chakra-ui/react" +import {chakra} from "@chakra-ui/react" +import type {HTMLMotionProps} from "framer-motion" +import {motion} from "framer-motion" + +import type {Merge} from "lib/types/merge" + +type MotionBoxProps = Merge, HTMLMotionProps<"div">> + +const MotionBox: React.FC = motion(chakra.div) + +export default MotionBox diff --git a/src/lib/components/navigation/AcountNavigation.tsx b/src/lib/components/navigation/AcountNavigation.tsx new file mode 100644 index 00000000..9c616a01 --- /dev/null +++ b/src/lib/components/navigation/AcountNavigation.tsx @@ -0,0 +1,196 @@ +import { + Avatar, + Button, + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerOverlay, + Flex, + HStack, + Icon, + Image, + Menu, + MenuButton, + MenuItem, + MenuList, + Select, + Show, + Text, + Tooltip, + useColorMode, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import {BsMoonStarsFill, BsSun} from "react-icons/bs" +import {HiOutlineBell, HiOutlineCog} from "react-icons/hi" +import React, {useState} from "react" +import {ChevronDownIcon} from "@chakra-ui/icons" +import Cookies from "universal-cookie" +import {ItemContent} from "../generic/ItemContent" +import ProtectedContent from "../auth/ProtectedContent" +import {appPermissions} from "lib/constants/app-permissions.config" +import {useAuth} from "lib/hooks/useAuth" +import {Link} from "./Link" + +const MobileNavigation = () => { + const {Logout} = useAuth() + let usersToken = typeof window !== "undefined" ? localStorage.getItem("usersToken") : "" + let menuBg = useColorModeValue("white", "navy.800") + const {isOpen, onOpen, onClose} = useDisclosure() + const btnRef = React.useRef() + const {colorMode, toggleColorMode} = useColorMode() + const color = useColorModeValue("textColor.900", "textColor.100") + const [selectedOption, setSelectedOption] = useState() + // This function is triggered when the select changes + const selectChange = (event: React.ChangeEvent) => { + const value = event.target.value + setSelectedOption(value) + const cookies = new Cookies() + cookies.set("currenttheme", value, { + path: "/" + }) + //Reload page so the theme takes affect + window.location.reload() + } + const cookies = new Cookies() + let currenttheme + let currentthemename + if (cookies.get("currenttheme") !== null) { + currenttheme = cookies.get("currenttheme") + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {usersToken} + + + + + + + + + + Manage Profile + + + + + + + Notifications + + + + Logout()}> + + Log Out + + + + + + + + + + Application Settings + + + + + + Change Theme: + + + + + + + Need help? + + Please check our docs. + + + + + + + + + + + + ) +} + +export default MobileNavigation diff --git a/src/lib/components/navigation/CategoryNavigation.tsx b/src/lib/components/navigation/CategoryNavigation.tsx new file mode 100644 index 00000000..56b57c33 --- /dev/null +++ b/src/lib/components/navigation/CategoryNavigation.tsx @@ -0,0 +1,41 @@ +import { + Menu, + MenuButton, + MenuList, + MenuItem, + MenuItemOption, + MenuGroup, + MenuOptionGroup, + MenuDivider, + Button +} from "@chakra-ui/react" + +import {ChevronDownIcon} from "@chakra-ui/icons" +import {HiMenu} from "react-icons/hi" + +const CategoryNavigation = () => { + return ( + + } + leftIcon={} + bg="brand.500" + color="textColor.100" + fontSize="x-small" + mr="4" + > + All Departments + + + Download + Create a Copy + Mark as Draft + Delete + Attend a Workshop + + + ) +} + +export default CategoryNavigation diff --git a/src/lib/components/navigation/DesktopSideBarMenu.tsx b/src/lib/components/navigation/DesktopSideBarMenu.tsx new file mode 100644 index 00000000..1e2d2418 --- /dev/null +++ b/src/lib/components/navigation/DesktopSideBarMenu.tsx @@ -0,0 +1,236 @@ +import {Button, Flex, Icon, IconButton, Image, Link as ChakraLink, Text, useColorModeValue} from "@chakra-ui/react" +import {FiSettings, FiStar} from "react-icons/fi" +import { + HiChevronDoubleLeft, + HiOutlineChartBar, + HiOutlineEmojiSad, + HiOutlineQrcode, + HiOutlineUser, + HiOutlineUserGroup +} from "react-icons/hi" +import React, {useState} from "react" +import {TbBuildingWarehouse, TbShoppingCartDiscount, TbShoppingCartPlus, TbTruckReturn} from "react-icons/tb" +import ProtectedContent from "../auth/ProtectedContent" +import {appPermissions} from "lib/constants/app-permissions.config" +import {Link} from "./Link" + +const DesktopSideBarMenu = () => { + const [navSize, changeNavSize] = useState("large") + const sidebarBg = useColorModeValue("brand.500", "brand.600") + const color = useColorModeValue("textColor.900", "textColor.100") + + return ( + <> + + + + + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {" "} + + + ) +} + +export default DesktopSideBarMenu diff --git a/src/lib/components/navigation/FooterLinksNavigation.tsx b/src/lib/components/navigation/FooterLinksNavigation.tsx new file mode 100644 index 00000000..10a4096d --- /dev/null +++ b/src/lib/components/navigation/FooterLinksNavigation.tsx @@ -0,0 +1,27 @@ +import {Flex, Text, VStack} from "@chakra-ui/react" +import {ReactNode} from "react" +import {Link} from "./Link" + +const FooterLinksNavigation = () => { + const ListHeader = ({children}: {children: ReactNode}) => { + return ( + + {children} + + ) + } + + return ( + + + Policies + Privacy Policy + Refund Policy + Cookie Policy + Terms & Conditions + + + ) +} + +export default FooterLinksNavigation diff --git a/src/lib/components/navigation/InformationNavigation.tsx b/src/lib/components/navigation/InformationNavigation.tsx new file mode 100644 index 00000000..db9c87d4 --- /dev/null +++ b/src/lib/components/navigation/InformationNavigation.tsx @@ -0,0 +1,33 @@ +import {Flex, Tag, Text, VStack, useColorMode, useColorModeValue} from "@chakra-ui/react" +import {Link} from "./Link" + +const InformationNavigation = () => { + const {colorMode, toggleColorMode} = useColorMode() + + return ( + + + + About Us + + Home + Blog + About Us + + Shop{" "} + + New + + + Contact Us + + + ) +} + +export default InformationNavigation diff --git a/src/lib/components/navigation/Link.tsx b/src/lib/components/navigation/Link.tsx new file mode 100644 index 00000000..851c323f --- /dev/null +++ b/src/lib/components/navigation/Link.tsx @@ -0,0 +1,22 @@ +import NextLink from "next/link" +import {Link as ChakraLink, HTMLChakraProps, ThemingProps} from "@chakra-ui/react" + +interface ChakraLinkProps extends HTMLChakraProps<"a">, ThemingProps<"Link"> { + /** + * If `true`, the link will open in new tab + * + * @default false + */ + isExternal?: boolean +} + +// combines chakra ui, with functionality needed for nextjs links +// https://jools.dev/using-nextjs-link-with-chakra-ui-link + +export const Link = ({href, children, ...props}: ChakraLinkProps) => { + return ( + + {children} + + ) +} diff --git a/src/lib/components/navigation/MainNavigation.tsx b/src/lib/components/navigation/MainNavigation.tsx new file mode 100644 index 00000000..baebc3a4 --- /dev/null +++ b/src/lib/components/navigation/MainNavigation.tsx @@ -0,0 +1,33 @@ +import {HStack} from "@chakra-ui/react" +import {useAuth} from "lib/hooks/useAuth" +import ProtectedContent from "../auth/ProtectedContent" +import {appPermissions} from "lib/constants/app-permissions.config" +import {Link} from "./Link" + +const MainNavigation = () => { + const {Logout} = useAuth() + return ( + + + + Products + + + + + Orders + + + + + Users + + + Logout()}> + Log out + + + ) +} + +export default MainNavigation diff --git a/src/lib/components/navigation/MobileSideBarMenu.tsx b/src/lib/components/navigation/MobileSideBarMenu.tsx new file mode 100644 index 00000000..a754150e --- /dev/null +++ b/src/lib/components/navigation/MobileSideBarMenu.tsx @@ -0,0 +1,72 @@ +import {Flex, Icon} from "@chakra-ui/react" +import {FiSettings} from "react-icons/fi" +import {HiOutlineChartBar, HiOutlineQrcode, HiOutlineUserGroup} from "react-icons/hi" +import {TbBuildingWarehouse, TbShoppingCartDiscount, TbShoppingCartPlus, TbTruckReturn} from "react-icons/tb" +import ProtectedContent from "../auth/ProtectedContent" +import React from "react" +import {appPermissions} from "lib/constants/app-permissions.config" +import {Link} from "./Link" + +const MobileSideBarMenu = () => { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default MobileSideBarMenu diff --git a/src/lib/components/navigation/ShoppingNavigation.tsx b/src/lib/components/navigation/ShoppingNavigation.tsx new file mode 100644 index 00000000..475cc648 --- /dev/null +++ b/src/lib/components/navigation/ShoppingNavigation.tsx @@ -0,0 +1,20 @@ +import {Flex, Link, VStack, Text} from "@chakra-ui/react" + +const ShoppingNavigation = () => { + return ( + + + + My Account + + Check Out + Cart + Products + Shop + Legal + + + ) +} + +export default ShoppingNavigation diff --git a/src/lib/components/navigation/SideNavigation.tsx b/src/lib/components/navigation/SideNavigation.tsx new file mode 100644 index 00000000..13209fa0 --- /dev/null +++ b/src/lib/components/navigation/SideNavigation.tsx @@ -0,0 +1,37 @@ +import {VStack, useMediaQuery, Flex} from "@chakra-ui/react" +import React, {useEffect, useState} from "react" + +import MobileSideBarMenu from "./MobileSideBarMenu" +import DesktopSideBarMenu from "./DesktopSideBarMenu" + +const SideNavigation = () => { + const [isMobile] = useMediaQuery("(max-width: 768px)") + return ( + + {isMobile ? : } + + ) +} + +export default SideNavigation diff --git a/src/lib/components/navigation/TopCategoriesNavigation.tsx b/src/lib/components/navigation/TopCategoriesNavigation.tsx new file mode 100644 index 00000000..b6ebba66 --- /dev/null +++ b/src/lib/components/navigation/TopCategoriesNavigation.tsx @@ -0,0 +1,21 @@ +import {Flex, VStack, Text} from "@chakra-ui/react" +import {Link} from "./Link" + +const TopCategoriesNavigation = () => { + return ( + + + + Top Categories + + Category 1 + Category 2 + Category 3 + Category 4 + Category 5 + + + ) +} + +export default TopCategoriesNavigation diff --git a/src/lib/components/ordercloud-table/OrderCloudTable.tsx b/src/lib/components/ordercloud-table/OrderCloudTable.tsx new file mode 100644 index 00000000..6ae7ad2f --- /dev/null +++ b/src/lib/components/ordercloud-table/OrderCloudTable.tsx @@ -0,0 +1,226 @@ +import { + Flex, + Stack, + Input, + Table, + Text, + Thead, + Tr, + Th, + Icon, + Tbody, + Td, + Select, + Skeleton, + Box, + Spinner +} from "@chakra-ui/react" +import {debounce, get} from "lodash" +import {ListPage} from "ordercloud-javascript-sdk" +import {useEffect, useMemo, useState} from "react" +import {TiArrowSortedDown, TiArrowSortedUp, TiArrowUnsorted} from "react-icons/ti" +import {OrderCloudTableFilters, OrderCloudTableColumn, OrderCloudTableHeaders, OrderCloudTableRow} from "./models" +import {PreviousNextButton} from "./PreviousNextButton" +import {PaginationButtons} from "./PaginationButtons" +import {PaginationInput} from "./PaginationInput" + +interface OrderCloudTableProps { + columns: OrderCloudTableColumn[] + data: ListPage + filters: OrderCloudTableFilters + fetchData: (filters: OrderCloudTableFilters) => Promise +} +export function OrderCloudTable({columns, data, fetchData, filters: appliedFilters}: OrderCloudTableProps) { + if (!columns) { + throw new Error("Required prop 'columns' is not defined for OrderCloudTable") + } + if (!appliedFilters) { + throw new Error("Required prop 'filters' is not defined for OrderCloudTable") + } + if (!fetchData) { + throw new Error("Required prop 'fetchData' is not defined for OrderCloudTable") + } + const headers = buildHeaders(columns) + const rows = data ? buildRows(columns, data) : buildSkeletonRows(columns) + const meta = data?.Meta || {} + const [loading, setLoading] = useState(false) + const [pageSize, setPageSize] = useState(meta.PageSize) + const [page, setPage] = useState(meta.Page) + const [filters, setFilters] = useState({ + page: meta.Page, + pageSize: meta.PageSize, + search: appliedFilters.search, + sortBy: appliedFilters.sortBy + } as OrderCloudTableFilters) + + useEffect(() => { + setLoading(false) + }, [data]) + + const debouncedFetchData = useMemo(() => debounce(fetchData, 300), [fetchData]) + + function buildHeaders(columns: OrderCloudTableColumn[]): OrderCloudTableHeaders[] { + return columns.map((column) => { + const isSorted = appliedFilters.sortBy?.length + ? appliedFilters.sortBy.includes(column.accessor) || appliedFilters.sortBy.includes(`!${column.accessor}`) + : false + const isSortedDesc = appliedFilters.sortBy?.length ? appliedFilters.sortBy.includes(`!${column.accessor}`) : false + return { + ...column, + isSorted, + isSortedDesc + } + }) + } + + function buildRows(columns: OrderCloudTableColumn[], data: ListPage): OrderCloudTableRow[] { + return data.Items.map((row) => ({ + cells: columns.map((column) => { + const value = get(row, column.accessor, null) + return { + value: column.Cell?.({value, row: {original: row}}) || value + } + }) + })) + } + + function buildSkeletonRows(columns: OrderCloudTableColumn[]): OrderCloudTableRow[] { + return Array(5) + .fill(null) + .map(() => ({ + cells: columns.map((column) => { + return { + value: + } + }) + })) + } + + const handlePageChange = (page: number) => { + setLoading(true) + const updatedFilters = {...filters, page} + setPage(page) + setFilters(updatedFilters) + fetchData(updatedFilters) + } + + const handlePageSizeChange = (pageSize: number) => { + setLoading(true) + const updatedFilters = {...filters, pageSize, page: 1} + setPage(1) + setPageSize(pageSize) + setFilters(updatedFilters) + fetchData(updatedFilters) + } + + const handleSearchChange = (search: string) => { + setLoading(true) + const updatedFilters = {...filters, search, page: 1} + setPage(1) + setFilters(updatedFilters) + debouncedFetchData(updatedFilters) + } + + return ( + + + + + {data && !loading + ? `Showing ${meta.ItemRange[0]} to ${meta.ItemRange[1]} of ${meta.TotalCount} results` + : "Loading..."} + + + + {loading && } + handleSearchChange(e.target.value)} + /> + + + + + + {headers.map((header, index) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {row.cells.map((cell, cellIndex) => ( + + ))} + + ))} + +
    + + {header.Header} + {header.canSort && ( + + )} + +
    + {cell.value} +
    + {meta.TotalCount > 10 && ( + + + + entries per page + + + {page !== 1 && } + {meta.TotalPages > 5 ? ( + + ) : ( + + )} + {page < meta.TotalPages && } + + + )} +
    + ) +} diff --git a/src/lib/components/ordercloud-table/PaginationButtons.tsx b/src/lib/components/ordercloud-table/PaginationButtons.tsx new file mode 100644 index 00000000..76f0ab3b --- /dev/null +++ b/src/lib/components/ordercloud-table/PaginationButtons.tsx @@ -0,0 +1,43 @@ +import {Button, Text} from "@chakra-ui/react" + +interface PaginationButtonsProps { + page: number + totalPages: number + onPageChange: (page: number) => void +} +export function PaginationButtons({page, totalPages, onPageChange}: PaginationButtonsProps) { + const buildPagesArray = (count) => { + // ex: count 3 returns [1, 2, 3] + return Array(count) + .fill(null) + .map((value, index) => index + 1) + } + + return ( + <> + {buildPagesArray(totalPages).map((pageNumber) => { + return ( + + ) + })} + + ) +} diff --git a/src/lib/components/ordercloud-table/PaginationInput.tsx b/src/lib/components/ordercloud-table/PaginationInput.tsx new file mode 100644 index 00000000..f377dc0b --- /dev/null +++ b/src/lib/components/ordercloud-table/PaginationInput.tsx @@ -0,0 +1,32 @@ +import { + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper +} from "@chakra-ui/react" +import React from "react" + +interface PaginationInputProps { + page: number + totalPages: number + onPageChange: (page: number) => void +} +export function PaginationInput({page, totalPages, onPageChange}: PaginationInputProps) { + return ( + onPageChange(parseInt(e))} + > + + + onPageChange(page + 1)} /> + onPageChange(page - 1)} /> + + + ) +} diff --git a/src/lib/components/ordercloud-table/PreviousNextButton.tsx b/src/lib/components/ordercloud-table/PreviousNextButton.tsx new file mode 100644 index 00000000..c2ce5bf3 --- /dev/null +++ b/src/lib/components/ordercloud-table/PreviousNextButton.tsx @@ -0,0 +1,30 @@ +import {Button, Icon} from "@chakra-ui/react" +import {GrFormNext, GrFormPrevious} from "react-icons/gr" + +interface PreviousNextButtonProps { + type: "previous" | "next" + page: number + onPageChange: (page: number) => void +} +export function PreviousNextButton({page, type, onPageChange}: PreviousNextButtonProps) { + return ( + + ) +} diff --git a/src/lib/components/ordercloud-table/index.tsx b/src/lib/components/ordercloud-table/index.tsx new file mode 100644 index 00000000..cf334e06 --- /dev/null +++ b/src/lib/components/ordercloud-table/index.tsx @@ -0,0 +1,5 @@ +export * from "./models" +export * from "./OrderCloudTable" +export * from "./PaginationButtons" +export * from "./PaginationInput" +export * from "./PreviousNextButton" diff --git a/src/lib/components/ordercloud-table/models.ts b/src/lib/components/ordercloud-table/models.ts new file mode 100644 index 00000000..ede65af7 --- /dev/null +++ b/src/lib/components/ordercloud-table/models.ts @@ -0,0 +1,29 @@ +import {ListPage} from "ordercloud-javascript-sdk" +import {ReactElement} from "react" + +export interface OrderCloudTableFilters { + page?: number + pageSize?: number + search?: string + sortBy?: any[] // this is a different type based on endpoint called so just use any[] +} + +export interface OrderCloudTableColumn { + Header: string + accessor?: string + Cell?: ({row, value}: {row: {original: T}; value: any}) => ReactElement | string + canSort?: boolean +} + +export interface OrderCloudTableHeaders extends OrderCloudTableColumn { + isSorted?: boolean + isSortedDesc?: boolean +} + +export interface OrderCloudTableCell { + value: ReactElement +} + +export interface OrderCloudTableRow { + cells: OrderCloudTableCell[] +} diff --git a/src/lib/components/orders/AddressCard.tsx b/src/lib/components/orders/AddressCard.tsx new file mode 100644 index 00000000..a6e4c511 --- /dev/null +++ b/src/lib/components/orders/AddressCard.tsx @@ -0,0 +1,30 @@ +import {Address} from "ordercloud-javascript-sdk" +import {Text, TextProps} from "@chakra-ui/react" + +export interface AddressCardProps { + address: Address + fontSize?: TextProps["size"] +} +const AddressCard = ({address, fontSize = "sm"}: AddressCardProps) => { + if (!address) { + return ( +
    + John Smith + 123 Sunrise Pointe + Pleasant Hill, MN 55604 +
    + ) + } + return ( +
    + {address.CompanyName || address.FirstName + " " + address.LastName} + {address.Street1} + {address.Street2 && {address.Street2}} + + {address.City}, {address.State} {address.Zip} + +
    + ) +} + +export default AddressCard diff --git a/src/lib/components/productfacets/CreateUpdateForm.tsx b/src/lib/components/productfacets/CreateUpdateForm.tsx new file mode 100644 index 00000000..5f58b9e7 --- /dev/null +++ b/src/lib/components/productfacets/CreateUpdateForm.tsx @@ -0,0 +1,232 @@ +import * as Yup from "yup" +import {Box, Button, ButtonGroup, Flex, FormLabel, HStack, Icon, Stack, Text} from "@chakra-ui/react" +import {InputControl} from "formik-chakra-ui" +import {ProductFacet} from "ordercloud-javascript-sdk" +import Card from "../card/Card" +import {Field, Formik} from "formik" +import {useRouter} from "next/router" +import {productfacetsService} from "lib/api/productfacets" +import {useEffect, useState, KeyboardEvent} from "react" +import {HiOutlineX} from "react-icons/hi" +import {useCreateUpdateForm} from "lib/hooks/useCreateUpdateForm" +import {xpHelper} from "lib/utils" + +export {CreateUpdateForm} + +interface CreateUpdateFormProps { + productfacet?: ProductFacet +} + +function CreateUpdateForm({productfacet}: CreateUpdateFormProps) { + const router = useRouter() + const formShape = { + Name: Yup.string().required("Name is required") + } + const {isCreating, successToast, errorToast, validationSchema, initialValues} = useCreateUpdateForm( + productfacet, + formShape, + createProductFacet, + updateProductFacet + ) + const [inputValue, setInputValue] = useState("") + const [facetOptions, setFacetOptions] = useState([]) + + useEffect(() => { + setFacetOptions(productfacet?.xp?.Options || []) + }, [productfacet?.xp?.Options]) + + function onSubmit(fields, {setStatus, setSubmitting}) { + setStatus() + fields.xp_Options = facetOptions + const productfacet = xpHelper.unflattenXpObject(fields, "_") as ProductFacet + if (isCreating) { + createProductFacet(productfacet) + } else { + updateProductFacet(productfacet) + } + setSubmitting() + } + + const handleAddButtonClick = () => { + const newFacetOptions = [...facetOptions, inputValue] + setFacetOptions(newFacetOptions) + setInputValue("") + } + const removeFacetOption = (index) => { + setFacetOptions((oldValues) => { + return oldValues.filter((_, i) => i !== index) + }) + } + + async function createProductFacet(fields: ProductFacet) { + await productfacetsService.create(fields) + successToast({ + description: "Product Facet created successfully." + }) + router.push(".") + } + + async function updateProductFacet(fields: ProductFacet) { + await productfacetsService.update(fields) + successToast({ + description: "Product Facet updated successfully." + }) + router.push(".") + } + + async function deleteProductFacets() { + try { + await productfacetsService.delete(router.query.id) + router.push(".") + successToast({ + description: "Product Facet deleted successfully." + }) + } catch (e) { + errorToast({ + description: "Product Facet delete failed" + }) + } + } + + const reset = () => { + setFacetOptions(productfacet.xp?.Options || []) + setInputValue("") + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault() // prevent form from being submitted + handleAddButtonClick() + } + } + + return ( + + + + {({ + // most of the usefull available Formik props + values, + errors, + touched, + handleChange, + handleBlur, + handleSubmit, + isSubmitting, + setFieldValue, + resetForm + }) => ( + + + + + Facet Options :Create options for this facet group? + + + + {facetOptions.map((facetOption, index) => ( + +
    + { + <> + + removeFacetOption(index)} + /> + {facetOption} + + + } +
    +
    + ))} +
    +
    + + + {({ + field, // { name, value, onChange, onBlur } + form: {touched, errors}, // also values, setXXXX, handleXXXX, dirty, isValid, status, etc. + meta + }) => ( +
    + setInputValue(event.target.value)} + onKeyDown={handleKeyDown} + className="add-facet-option-input" + placeholder="Add a facet value..." + /> + {meta.touched && meta.error &&
    {meta.error}
    } +
    + )} +
    + +
    + + + + + + + + + + +
    +
    + )} +
    +
    +
    + ) +} diff --git a/src/lib/components/productfacets/index.tsx b/src/lib/components/productfacets/index.tsx new file mode 100644 index 00000000..24db4ac7 --- /dev/null +++ b/src/lib/components/productfacets/index.tsx @@ -0,0 +1 @@ +export * from "./CreateUpdateForm" diff --git a/src/lib/components/products/EditorialProgressBar.tsx b/src/lib/components/products/EditorialProgressBar.tsx new file mode 100644 index 00000000..b163dcff --- /dev/null +++ b/src/lib/components/products/EditorialProgressBar.tsx @@ -0,0 +1,85 @@ +import {Box, Progress, Heading, Tooltip, useColorModeValue} from "@chakra-ui/react" +import {ProductXPs} from "lib/types/ProductXPs" +import {Product, RequiredDeep} from "ordercloud-javascript-sdk" +import {useState, useEffect} from "react" + +type ProductDataProps = { + product: RequiredDeep> +} + +export function CalculateEditorialProcess(product: Product): number { + var totalNumberOfFieldsToEdit = 4 + var currentNumberOfEditedFields = 0 + + if ((product?.Description ?? "") != "") { + currentNumberOfEditedFields++ + } + if ((product?.DefaultPriceScheduleID ?? "") != "") { + currentNumberOfEditedFields++ + } + if (product?.Active ?? false) { + currentNumberOfEditedFields++ + } + if ((typeof product?.xp?.Images != "undefined" ? product?.xp?.Images[0].Url : "") != "") { + currentNumberOfEditedFields++ + } + + var calculatedProgress = (currentNumberOfEditedFields / totalNumberOfFieldsToEdit) * 100 + + return calculatedProgress +} + +export default function EditorialProgressBar({product}: ProductDataProps) { + const [progress, setProgress] = useState(0) + const [progressColor, setProgressColor] = useState("") + const [isLoading, setIsLoading] = useState(false) + const color = useColorModeValue("textColor.900", "textColor.100") + + useEffect(() => { + setIsLoading(true) + + var calculatedProgress = CalculateEditorialProcess(product) + setProgress(calculatedProgress) + + let colorSchema = "" + if (calculatedProgress == 0) { + colorSchema = "blue" + } + if (calculatedProgress <= 25) { + colorSchema = "red" + } else if (calculatedProgress > 25 && calculatedProgress <= 50) { + colorSchema = "orange" + } else if (calculatedProgress > 50 && calculatedProgress <= 75) { + colorSchema = "yellow" + } else { + colorSchema = "green" + } + setProgressColor(colorSchema) + setIsLoading(false) + }, [product]) + + const heading = ( + + Editorial Progress {product && !isLoading ? ": " + progress + "%" : "..."} + + ) + + return ( + + + {progress < 100 ? ( + + {heading} + + ) : ( + heading + )} + + ) +} diff --git a/src/lib/components/products/ProductCard.tsx b/src/lib/components/products/ProductCard.tsx new file mode 100644 index 00000000..80f8bcb3 --- /dev/null +++ b/src/lib/components/products/ProductCard.tsx @@ -0,0 +1,71 @@ +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {Checkbox, Flex, Heading, Image, Spacer, Text, Tooltip, VStack, useColorModeValue} from "@chakra-ui/react" +import {textHelper} from "lib/utils/text.utils" +import {Product} from "ordercloud-javascript-sdk" +import {Link} from "../navigation/Link" + +interface ProductCardProps { + product: Product + selected: boolean + onProductSelected: (productId: string, selected: boolean) => void +} +const ProductCard = (props: ProductCardProps) => { + const product = props.product + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + + return ( + + + props.onProductSelected(product.ID, e.target.checked)} /> + + + + 0 + ? product?.xp?.Images[0]?.ThumbnailUrl || product?.xp?.Images[0]?.Url || product?.xp?.Images[0]?.url + : "/images/dummy-image-square.jpg" + } + alt="product image" + width="175px" + /> + + + +

    Active

    + {product.Active ? : } +
    + +
    + + {/* NEW ARRIVALS */} + + + + {product.Name.length > 39 ? product.Name.substring(0, 39) + "..." : product.Name} + + + + + + {textHelper.stripHTML(product.Description).length > 40 + ? textHelper.stripHTML(product.Description).substring(0, 40) + "..." + : textHelper.stripHTML(product.Description)} + + + +
    + ) +} + +export default ProductCard diff --git a/src/lib/components/products/ProductCatalogAssignments.tsx b/src/lib/components/products/ProductCatalogAssignments.tsx new file mode 100644 index 00000000..b5858494 --- /dev/null +++ b/src/lib/components/products/ProductCatalogAssignments.tsx @@ -0,0 +1,329 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + Collapse, + FormControl, + HStack, + Heading, + Input, + ListItem, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tooltip, + Tr, + UnorderedList, + useColorMode, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import { + Catalog, + CatalogAssignment, + Catalogs, + ListPage, + Product, + ProductCatalogAssignment, + Products, + RequiredDeep +} from "ordercloud-javascript-sdk" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {FiEdit, FiPlus, FiTrash2} from "react-icons/fi" +import {useEffect, useState} from "react" + +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" +import ProductCategoryAssignments from "./ProductCategoryAssignments" +import React from "react" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductCatalogAssignments({composedProduct, setComposedProduct}: ProductDataProps) { + const [productCatalogAssignments, setProductCatalogAssignments] = useState(null) + const [chosenCatalog, setChosenCatalog] = useState(null) + const {isOpen, onOpen, onClose} = useDisclosure() + const cancelRef = React.useRef() + const [isLinking, setIsLinking] = useState(false) + const [availableCatalogs, setAvailableCatalogs] = useState[]>(null) + const [isCatalogChosen, setIsCatalogChosen] = useState(false) + const [newCatalog, setNewCatalog] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const color = useColorModeValue("textColor.900", "textColor.100") + const [expanded, setExpanded] = useState(true) + + useEffect(() => { + async function GetProductCatalogAssignments() { + if (composedProduct?.Product) { + setIsLoading(true) + let catalogs: Catalog[] = [] + const assignments = await Catalogs.ListProductAssignments({ + catalogID: "", + productID: composedProduct.Product?.ID + }) + + await Promise.all( + assignments.Items.map(async (index) => { + var catalog = await Catalogs.Get(index.CatalogID) + catalogs.push(catalog) + }) + ) + + setProductCatalogAssignments(catalogs) + setIsLoading(false) + } + } + GetProductCatalogAssignments() + }, [composedProduct?.Product]) + + const onCategoriesExpandedClick = async (e) => { + const chosenCatalogId = e.currentTarget.dataset.id + const newValue = chosenCatalogId == (chosenCatalog?.ID ?? "") ? "" : chosenCatalogId + if (newValue == "") { + setChosenCatalog(null) + } else { + var catalog = await Catalogs.Get(newValue) + setChosenCatalog(catalog) + } + } + + const onRemoveCatalog = async (e) => { + e.preventDefault() + setIsLoading(true) + const catalogId = e.currentTarget.dataset.id + await Catalogs.DeleteProductAssignment(catalogId, composedProduct?.Product?.ID) + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setChosenCatalog(null) + setIsLoading(false) + } + + const onCatalogLink = async (e) => { + setIsLinking(true) + e.preventDefault() + const catalogAssignment: ProductCatalogAssignment = { + CatalogID: newCatalog, + ProductID: composedProduct?.Product?.ID + } + + await Catalogs.SaveProductAssignment(catalogAssignment) + + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsLinking(false) + setNewCatalog("") + setAvailableCatalogs(null) + setExpanded(true) + onClose() + } + + const onAvailableCatalogClick = (e) => { + e.preventDefault() + const chosenCatalog = e.currentTarget.dataset.id + setNewCatalog(chosenCatalog) + setIsCatalogChosen(true) + } + + const onCatalogLinkInputChanged = (e) => { + e.preventDefault() + setIsCatalogChosen(false) + setNewCatalog(e.target.value) + Catalogs.List({ + searchOn: ["Name", "ID"], + search: e.target.value + }).then((innerAvailableCatalogs) => { + const catalogIds = productCatalogAssignments.map((item) => { + return item.ID + }) + const filteredCatalogs = innerAvailableCatalogs.Items.filter( + (innerCatalog) => !catalogIds.includes(innerCatalog.ID) + ) + setAvailableCatalogs(filteredCatalogs) + }) + } + + return ( + <> + <> + Catalog Assignments + {!composedProduct && expanded ? ( + + Updating... + + ) : ( + + + {/* + Assignments + */} + {productCatalogAssignments?.length ?? 0 > 0 ? ( + <> + + + + ID + Name + Action + + + + {productCatalogAssignments ? ( + <> + {productCatalogAssignments.map((item, index) => { + return ( + + + + {item.ID} + + + {item.Name} + + {" "} + + + + + + ) + })} + + ) : ( + No Assignments + )} + + + + ) : ( + No Assignments + )} + + {chosenCatalog ? ( + + ) : ( + <> + )} + + )} + + + + + + + + + + {isLinking ? ( + + Linking... + + ) : ( + <> + + Link a Catalog to Product + + + Please choose Catalog to link + + + + {(availableCatalogs?.length ?? 0) > 0 ? ( + <> + + + Available Catalogs (Please choose...) + + + {availableCatalogs.map((element, key) => ( + + + Name: {element.Name} | ID: {element.ID} + + + ))} + + + + ) : null} + + + {isCatalogChosen ? null : ( + Please choose from the search results to link a Catalog + )} + + + + + + )} + + + + + ) +} diff --git a/src/lib/components/products/ProductCategoryAssignments.tsx b/src/lib/components/products/ProductCategoryAssignments.tsx new file mode 100644 index 00000000..b0e01cf2 --- /dev/null +++ b/src/lib/components/products/ProductCategoryAssignments.tsx @@ -0,0 +1,288 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + FormControl, + Heading, + HStack, + Input, + ListItem, + Tbody, + Td, + Text, + Th, + Thead, + Tooltip, + Tr, + UnorderedList, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import { + Catalog, + Categories, + Category, + CategoryProductAssignment, + Product, + Products, + RequiredDeep +} from "ordercloud-javascript-sdk" +import React from "react" +import {useEffect, useState} from "react" +import {FiPlus, FiTrash2} from "react-icons/fi" +import BrandedBox from "../branding/BrandedBox" +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" + +type ProductDataProps = { + product: RequiredDeep> + catalog: Catalog +} + +export default function ProductCategoryAssignments({product, catalog}: ProductDataProps) { + const [productCategoryAssignments, setProductCategoryAssignments] = useState(null) + const [componentProduct, setComponentProduct] = useState>>(product) + const [isLoading, setIsLoading] = useState(false) + const {isOpen, onOpen, onClose} = useDisclosure() + const cancelRef = React.useRef() + const [isLinking, setIsLinking] = useState(false) + const [availableCategories, setAvailableCategories] = useState[]>(null) + const [isCategoryChosen, setIsCategoryChosen] = useState(false) + const [newCategory, setNewCategory] = useState("") + + const color = useColorModeValue("textColor.900", "textColor.100") + const bg = useColorModeValue("brand.500", "brand.500") + + useEffect(() => { + async function GetAssignments() { + let categories: Category[] = [] + if (catalog) { + setIsLoading(true) + const categoryAssignments = await Categories.ListProductAssignments(catalog?.ID, { + productID: componentProduct.ID + }) + + await Promise.all( + categoryAssignments.Items.map(async (item) => { + const category = await Categories.Get(catalog?.ID, item.CategoryID) + categories.push(category) + }) + ) + setIsLoading(false) + } + + setProductCategoryAssignments(categories) + } + GetAssignments() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [catalog]) + + const onRemoveCategory = async (e) => { + e.preventDefault() + setIsLoading(true) + const categoryId = e.currentTarget.dataset.id + await Categories.DeleteProductAssignment(catalog.ID, categoryId, componentProduct.ID) + var product = await Products.Get(componentProduct.ID) + setComponentProduct(product) + setNewCategory("") + setIsLoading(false) + } + + const onCategoryLink = async (e) => { + setIsLinking(true) + e.preventDefault() + const categoryAssignment: CategoryProductAssignment = { + CategoryID: newCategory, + ProductID: product.ID + } + + await Categories.SaveProductAssignment(catalog.ID, categoryAssignment) + + var product = await Products.Get(componentProduct.ID) + setComponentProduct(product) + setIsLinking(false) + setNewCategory("") + setAvailableCategories(null) + onClose() + } + + const onAvailableCategoryClick = (e) => { + e.preventDefault() + const chosenCategory = e.currentTarget.dataset.id + setNewCategory(chosenCategory) + setIsCategoryChosen(true) + } + + const onCategoryLinkInputChanged = (e) => { + e.preventDefault() + setIsCategoryChosen(false) + setNewCategory(e.target.value) + Categories.List(catalog.ID, { + searchOn: ["Name", "ID"], + search: e.target.value + }).then((innerAvailableCategories) => { + const categoryIds = productCategoryAssignments.map((item) => { + return item.ID + }) + const filteredCatalogs = innerAvailableCategories.Items.filter( + (innerCategory) => !categoryIds.includes(innerCategory.ID) + ) + setAvailableCategories(filteredCatalogs) + }) + } + + return ( + <> + + <> + + + + + + + Category Assigments of {catalog?.Name} + + {isLoading ? ( + + Updating... + + ) : ( + + {/* + Assignments + */} + + + + ID + Name + Action + + + + {productCategoryAssignments?.length > 0 ? ( + <> + {productCategoryAssignments.map((item, index) => { + return ( + + {item.ID} + {item.Name} + + {" "} + + + + + + ) + })} + + ) : ( + No Assignments + )} + + + + )} + + + + + + {isLinking ? ( + + Linking... + + ) : ( + <> + + Link a Category to Product + + + Please choose Category to link + + + + {(availableCategories?.length ?? 0) > 0 ? ( + <> + + + Available Categories (Please choose...) + + + {availableCategories.map((element, key) => ( + + + Name: {element.Name} | ID: {element.ID} + + + ))} + + + + ) : null} + + + {isCategoryChosen ? null : ( + Please choose from the search results to link a Category + )} + + + + + + )} + + + + + ) +} diff --git a/src/lib/components/products/ProductData.tsx b/src/lib/components/products/ProductData.tsx new file mode 100644 index 00000000..930c9fc3 --- /dev/null +++ b/src/lib/components/products/ProductData.tsx @@ -0,0 +1,332 @@ +import { + Box, + Button, + Checkbox, + Collapse, + Container, + Flex, + HStack, + Heading, + Input, + Text, + Tooltip, + useColorModeValue, + Switch +} from "@chakra-ui/react" +import {ChangeEvent, useEffect, useState} from "react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {FiCheck, FiEdit, FiX} from "react-icons/fi" +import {Product, Products} from "ordercloud-javascript-sdk" + +import BrandedBox from "../branding/BrandedBox" +import BrandedSpinner from "../branding/BrandedSpinner" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductData({composedProduct, setComposedProduct}: ProductDataProps) { + const [isEditingBasicData, setIsEditingBasicData] = useState(false) + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const color = useColorModeValue("textColor.100", "textColor.300") + const [expanded, setExpanded] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const [formValues, setFormValues] = useState({ + name: composedProduct?.Product?.Name, + id: composedProduct?.Product?.ID, + description: composedProduct?.Product?.Description, + defaultPriceScheduleId: composedProduct?.Product?.DefaultPriceScheduleID, + quantityMultiplier: composedProduct?.Product?.QuantityMultiplier, + shipFromAddress: composedProduct?.Product?.ShipFromAddressID, + returnable: composedProduct?.Product?.Returnable, + isActive: composedProduct?.Product?.Active, + allSuppliersCanSell: composedProduct?.Product?.AllSuppliersCanSell, + defaultSupplierId: composedProduct?.Product?.DefaultSupplierID + }) + + useEffect(() => { + setFormValues({ + name: composedProduct?.Product?.Name, + id: composedProduct?.Product?.ID, + description: composedProduct?.Product?.Description, + defaultPriceScheduleId: composedProduct?.Product?.DefaultPriceScheduleID, + quantityMultiplier: composedProduct?.Product?.QuantityMultiplier, + shipFromAddress: composedProduct?.Product?.ShipFromAddressID, + returnable: composedProduct?.Product?.Returnable, + isActive: composedProduct?.Product?.Active, + allSuppliersCanSell: composedProduct?.Product?.AllSuppliersCanSell, + defaultSupplierId: composedProduct?.Product?.DefaultSupplierID + }) + }, [ + composedProduct?.Product?.Active, + composedProduct?.Product?.AllSuppliersCanSell, + composedProduct?.Product?.DefaultPriceScheduleID, + composedProduct?.Product?.DefaultSupplierID, + composedProduct?.Product?.Description, + composedProduct?.Product?.ID, + composedProduct?.Product?.Name, + composedProduct?.Product?.QuantityMultiplier, + composedProduct?.Product?.Returnable, + composedProduct?.Product?.ShipFromAddressID + ]) + + const handleInputChange = (fieldKey: string) => (e: ChangeEvent) => { + if (fieldKey == "name" && e.target.value == "") { + return + } + setFormValues((v) => ({...v, [fieldKey]: e.target.value})) + } + + const handleNumberInputChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({ + ...v, + [fieldKey]: e.target.value == "" ? 0 : e.target.value + })) + } + + const handleCheckboxChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({...v, [fieldKey]: !!e.target.checked})) + } + + const onEditClicked = (e) => { + setFormValues((v) => ({ + ...v, + ["name"]: composedProduct?.Product?.Name, + ["id"]: composedProduct?.Product?.ID, + ["description"]: composedProduct?.Product?.Description, + ["defaultPriceScheduleId"]: composedProduct?.Product?.DefaultPriceScheduleID, + ["quantityMultiplier"]: composedProduct?.Product?.QuantityMultiplier, + ["shipFromAddress"]: composedProduct?.Product?.ShipFromAddressID, + ["returnable"]: composedProduct?.Product?.Returnable, + ["isActive"]: composedProduct?.Product?.Active, + ["allSuppliersCanSell"]: composedProduct?.Product?.AllSuppliersCanSell + })) + setIsEditingBasicData(true) + setExpanded(true) + } + + const onAbortClicked = (e) => { + setFormValues((v) => ({ + ...v, + ["name"]: composedProduct?.Product?.Name, + ["id"]: composedProduct?.Product?.ID, + ["description"]: composedProduct?.Product?.Description, + ["defaultPriceScheduleId"]: composedProduct?.Product?.DefaultPriceScheduleID, + ["quantityMultiplier"]: composedProduct?.Product?.QuantityMultiplier, + ["shipFromAddress"]: composedProduct?.Product?.ShipFromAddressID, + ["returnable"]: composedProduct?.Product?.Returnable, + ["isActive"]: composedProduct?.Product?.Active, + ["allSuppliersCanSell"]: composedProduct?.Product?.AllSuppliersCanSell + })) + setIsEditingBasicData(false) + } + + const onSaveClicked = async (e) => { + setIsLoading(true) + const patchedProduct: Product = { + Name: formValues.name, + Active: formValues.isActive, + AllSuppliersCanSell: formValues.allSuppliersCanSell, + DefaultPriceScheduleID: formValues.defaultPriceScheduleId, + Description: formValues.description, + // ID: formValues.id, + QuantityMultiplier: formValues.quantityMultiplier, + Returnable: formValues.returnable, + ShipFromAddressID: formValues.shipFromAddress + } + await Products.Patch(composedProduct?.Product?.ID, patchedProduct) + + // Hack to ensure Data are loaded before showing -> AWAIT is not enough + setTimeout(async () => { + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setTimeout(() => { + setIsEditingBasicData(false) + setIsLoading(false) + }, 2500) + }, 4500) + } + + return ( + <> + <> + {(!composedProduct?.Product || isLoading) && expanded ? ( + + Updating... + + ) : ( + <> + + Product Data + + + + + + + Product Name: + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + ID: + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + + Default Price Schedule ID: + {isEditingBasicData ? ( + + ) : ( + + )} + + + + Description: + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + + Ship from Address: + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + Default Supplier ID + {isEditingBasicData ? ( + + ) : ( + + )} + + + + All Suppliers can sell? + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + + Quantity Multiplier: + {isEditingBasicData ? ( + + ) : ( + + )} + + + + Returnable? + {isEditingBasicData ? ( + + ) : ( + + )} + + + Is Active + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + + )} + + {isEditingBasicData ? ( + + + + + + + + + ) : ( + + + + + + )} + + ) +} diff --git a/src/lib/components/products/ProductGrid.tsx b/src/lib/components/products/ProductGrid.tsx new file mode 100644 index 00000000..4417a487 --- /dev/null +++ b/src/lib/components/products/ProductGrid.tsx @@ -0,0 +1,72 @@ +import {Text, Tbody, Td, Tr, Box, Grid, GridItem, Checkbox} from "@chakra-ui/react" +import {useEffect, useState} from "react" +import {Product} from "ordercloud-javascript-sdk" +import {ProductXPs} from "lib/types/ProductXPs" +import ProductCard from "./ProductCard" + +interface ProductGridProps { + products: Product[] + selectedProductIds: string[] + onProductSelected: (productId: string, selected: boolean) => void + onToggleSelectAllProducts: () => void +} +const ProductGrid = (props: ProductGridProps) => { + const [componentProducts, setComponentProducts] = useState[]>(props.products) + + useEffect(() => { + setComponentProducts(props.products) + }, [props.products]) + + return ( + <> + {componentProducts ? ( + + + + props.onToggleSelectAllProducts()} + > + Select All + + + {componentProducts && componentProducts.length > 0 ? ( + componentProducts.map((p) => ( + + + + )) + ) : ( + No Products found + )} + + + + + ) : ( + + + {componentProducts.length} out of {componentProducts.length} + Products + + + )} + + ) +} +export default ProductGrid diff --git a/src/lib/components/products/ProductInventoryData.tsx b/src/lib/components/products/ProductInventoryData.tsx new file mode 100644 index 00000000..ba5e8842 --- /dev/null +++ b/src/lib/components/products/ProductInventoryData.tsx @@ -0,0 +1,273 @@ +import { + Box, + Button, + Checkbox, + Collapse, + Container, + Flex, + HStack, + Heading, + Input, + Spinner, + Text, + Tooltip, + useColorMode, + useColorModeValue, + Switch +} from "@chakra-ui/react" +import {ChangeEvent, useEffect, useState} from "react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {FiCheck, FiEdit, FiMinus, FiPlus, FiX} from "react-icons/fi" +import {Inventory, Product, Products, RequiredDeep} from "ordercloud-javascript-sdk" + +import BrandedBox from "../branding/BrandedBox" +import BrandedSpinner from "../branding/BrandedSpinner" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductInventoryData({composedProduct, setComposedProduct}: ProductDataProps) { + const [isEditingBasicData, setIsEditingBasicData] = useState(false) + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [expanded, setExpanded] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const [formValues, setFormValues] = useState({ + inventoryEnabled: composedProduct?.Product?.Inventory?.Enabled, + lastUpdated: composedProduct?.Product?.Inventory?.LastUpdated, + notificationPoint: composedProduct?.Product?.Inventory?.NotificationPoint, + orderCanExceed: composedProduct?.Product?.Inventory?.OrderCanExceed, + variantLevelTracking: composedProduct?.Product?.Inventory?.VariantLevelTracking, + quantityAvailable: composedProduct?.Product?.Inventory?.QuantityAvailable + }) + + const handleInputChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({...v, [fieldKey]: e.target.value})) + } + + const handleNumberInputChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({ + ...v, + [fieldKey]: e.target.value == "" ? 0 : e.target.value + })) + } + + const handleCheckboxChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({...v, [fieldKey]: !!e.target.checked})) + } + + const onEditClicked = (e) => { + setFormValues((v) => ({ + ...v, + ["inventoryEnabled"]: composedProduct?.Product?.Inventory?.Enabled, + ["lastUpdated"]: composedProduct?.Product?.Inventory?.LastUpdated, + ["notificationPoint"]: composedProduct?.Product?.Inventory?.NotificationPoint, + ["orderCanExceed"]: composedProduct?.Product?.Inventory?.OrderCanExceed, + ["variantLevelTracking"]: composedProduct?.Product?.Inventory?.VariantLevelTracking, + ["quantityAvailable"]: composedProduct?.Product?.Inventory?.QuantityAvailable + })) + setIsEditingBasicData(true) + setExpanded(true) + } + + const onAbortClicked = (e) => { + setFormValues((v) => ({ + ...v, + ["inventoryEnabled"]: composedProduct?.Product?.Inventory?.Enabled, + ["lastUpdated"]: composedProduct?.Product?.Inventory?.LastUpdated, + ["notificationPoint"]: composedProduct?.Product?.Inventory?.NotificationPoint, + ["orderCanExceed"]: composedProduct?.Product?.Inventory?.OrderCanExceed, + ["variantLevelTracking"]: composedProduct?.Product?.Inventory?.VariantLevelTracking, + ["quantityAvailable"]: composedProduct?.Product?.Inventory?.QuantityAvailable + })) + setIsEditingBasicData(false) + } + + useEffect(() => { + setFormValues({ + inventoryEnabled: composedProduct?.Product?.Inventory?.Enabled, + lastUpdated: composedProduct?.Product?.Inventory?.LastUpdated, + notificationPoint: composedProduct?.Product?.Inventory?.NotificationPoint, + orderCanExceed: composedProduct?.Product?.Inventory?.OrderCanExceed, + variantLevelTracking: composedProduct?.Product?.Inventory?.VariantLevelTracking, + quantityAvailable: composedProduct?.Product?.Inventory?.QuantityAvailable + }) + }, [ + composedProduct?.Product?.Inventory?.Enabled, + composedProduct?.Product?.Inventory?.LastUpdated, + composedProduct?.Product?.Inventory?.NotificationPoint, + composedProduct?.Product?.Inventory?.OrderCanExceed, + composedProduct?.Product?.Inventory?.QuantityAvailable, + composedProduct?.Product?.Inventory?.VariantLevelTracking + ]) + + const onSaveClicked = async (e) => { + setIsLoading(true) + const patchedInventory: Inventory = { + Enabled: formValues.inventoryEnabled, + // LastUpdated: formValues.lastUpdated, + NotificationPoint: formValues.notificationPoint, + OrderCanExceed: formValues.orderCanExceed, + QuantityAvailable: formValues.quantityAvailable, + VariantLevelTracking: formValues.variantLevelTracking + } + + const patchedProduct: Product = { + Name: composedProduct?.Product?.Name, + Inventory: patchedInventory + } + await Products.Patch(composedProduct?.Product?.ID, patchedProduct) + + // Hack to ensure Data are loaded before showing -> AWAIT is not enough + setTimeout(async () => { + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setTimeout(() => { + setIsEditingBasicData(false) + setIsLoading(false) + }, 2000) + }, 4000) + } + + return ( + <> + <> + {(!composedProduct?.Product || isLoading) && expanded ? ( + + Updating... + + ) : ( + <> + + Inventory + + + + + + Inventory Enabled?: + + + + + + Last Updated: + + {isEditingBasicData ? ( + + ) : ( + + {new Date(composedProduct?.Product?.Inventory?.LastUpdated)?.toLocaleString() ?? "Not set"} + + )} + + + + Notification Point: + {isEditingBasicData ? ( + + ) : ( + + {composedProduct?.Product?.Inventory?.NotificationPoint ?? "Not set"} + + )} + + + + Order can exceed?: + + {isEditingBasicData ? ( + + ) : ( + + )} + + + + + Variant Level Tracking?: + + + + + + Quantity Available: + + {isEditingBasicData ? ( + + ) : ( + + {composedProduct?.Product?.Inventory?.QuantityAvailable ?? "Not set"} + + )} + + + + + + + )} + + {isEditingBasicData ? ( + + + + + + + + + ) : ( + + + + + + )} + + ) +} diff --git a/src/lib/components/products/ProductInventoryRecords.tsx b/src/lib/components/products/ProductInventoryRecords.tsx new file mode 100644 index 00000000..c7490e6b --- /dev/null +++ b/src/lib/components/products/ProductInventoryRecords.tsx @@ -0,0 +1,148 @@ +import { + Box, + Button, + Divider, + HStack, + Heading, + Tbody, + Td, + Th, + Thead, + Tooltip, + Tr, + useColorModeValue +} from "@chakra-ui/react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {FiTrash2} from "react-icons/fi" +import {InventoryRecord, InventoryRecords} from "ordercloud-javascript-sdk" +import React, {useEffect} from "react" + +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" +import {ComposedProduct} from "../../services/ordercloud.service" +import {useState} from "react" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductInventoryRecords({composedProduct, setComposedProduct}: ProductDataProps) { + const color = useColorModeValue("textColor.900", "textColor.100") + const bg = useColorModeValue("brand.500", "brand.500") + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [expanded, setExpanded] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [inventoryRecors, setInventoryRecords] = useState(null) + + useEffect(() => { + async function GetProdcutSupplier() { + if (composedProduct?.Product) { + var productSupplier = await InventoryRecords.List(composedProduct?.Product?.ID) + setInventoryRecords(productSupplier.Items) + } + } + GetProdcutSupplier() + }, [composedProduct]) + + return ( + <> + <> + + Inventory Records + + + {(isLoading || !composedProduct?.Product) && expanded ? ( + + Updating... + + ) : ( + <> + + {(inventoryRecors?.length ?? 0) == 0 ? ( + <>No Inventory Records + ) : ( + + + + ID + Quantity + Address + Last Updated + Order can exceed? + Action + + + + {inventoryRecors?.map((item, index) => { + return ( + + {item.ID} + {item.QuantityAvailable} + + +

    + {item.Address.AddressName} +

    + +

    + {item.Address.FirstName} {item.Address.LastName} +

    +

    {item.Address.Street1}

    +

    + {item.Address.Zip} {item.Address.City} +

    +

    {item.Address.Country}

    +
    + + + {new Date(composedProduct?.Product?.Inventory?.LastUpdated)?.toLocaleString()} + + {item.OrderCanExceed ?? false ? ( + + ) : ( + + )} + + + {" "} + + + + + + ) + })} + +
    + )} +
    + + )} + + + + + + + + ) +} diff --git a/src/lib/components/products/ProductList.tsx b/src/lib/components/products/ProductList.tsx new file mode 100644 index 00000000..a913e0be --- /dev/null +++ b/src/lib/components/products/ProductList.tsx @@ -0,0 +1,186 @@ +import { + Center, + Checkbox, + Flex, + Image, + Tbody, + Td, + Text, + Th, + Thead, + Tooltip, + Tr, + useColorModeValue +} from "@chakra-ui/react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {FiArrowDown, FiArrowRight, FiArrowUp, FiCheckSquare} from "react-icons/fi" +import {useEffect, useState} from "react" +import BrandedSpinner from "../branding/BrandedSpinner" +import {CalculateEditorialProcess} from "./EditorialProgressBar" +import {Product} from "ordercloud-javascript-sdk" +import {textHelper} from "lib/utils/text.utils" +import {Link} from "../navigation/Link" + +interface ProductListProps { + products: Product[] + selectedProductIds: string[] + sortBy: string + onSort: (columName: string) => void + onProductSelected: (productId: string, selected: boolean) => void + onToggleSelectAllProducts: () => void +} +const ProductList = (props: ProductListProps) => { + const [componentProducts, setComponentProducts] = useState(props.products) + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [sortBy, setSortBy] = useState("") + const onSortByNameClickedInside = (columnName) => { + setSortBy(columnName) + props.onSort(columnName) + } + useEffect(() => { + setComponentProducts(props.products) + setSortBy(props.sortBy) + }, [props.products, props.sortBy]) + + return ( + <> + {componentProducts ? ( + <> + + + + props.onToggleSelectAllProducts()} + > + Select All + + + + + + + Product ID + {sortBy == "ID" ? ( + onSortByNameClickedInside("!ID")} /> + ) : sortBy == "!ID" ? ( + onSortByNameClickedInside("ID")} /> + ) : ( + onSortByNameClickedInside("ID")} /> + )} + + + Image + + + + Product Name + {sortBy == "name" ? ( + onSortByNameClickedInside("!name")} /> + ) : sortBy == "!name" ? ( + onSortByNameClickedInside("name")} /> + ) : ( + onSortByNameClickedInside("name")} /> + )} + + + + Description + {/* Description */} + + + + Active? + {sortBy == "Active" ? ( + onSortByNameClickedInside("!Active")} /> + ) : sortBy == "!Active" ? ( + onSortByNameClickedInside("Active")} /> + ) : ( + onSortByNameClickedInside("Active")} /> + )} + + + + + + Qty + + + + + + Editorial Progress + {sortBy == "editorialProcess" ? ( + onSortByNameClickedInside("!editorialProcess")} + /> + ) : sortBy == "!editorialProcess" ? ( + onSortByNameClickedInside("editorialProcess")} /> + ) : ( + onSortByNameClickedInside("editorialProcess")} /> + )} + + + + + + + {componentProducts && componentProducts.length > 0 ? ( + componentProducts.map((product, index) => ( + + + props.onProductSelected(product.ID, event.target.checked)} + /> + {product.ID} + + +
    + + product image + +
    + + + {product.Name} + + + {textHelper.stripHTML(product.Description).length > 40 + ? textHelper.stripHTML(product.Description).substring(0, 40) + "..." + : textHelper.stripHTML(product.Description)} + + + {product.Active ? ( + + ) : ( + + )} + + {product?.Inventory?.QuantityAvailable} + {CalculateEditorialProcess(product)}% + + )) + ) : ( + No Products found + )} + + + ) : ( + + )} + + ) +} +export default ProductList diff --git a/src/lib/components/products/ProductMeasurementData.tsx b/src/lib/components/products/ProductMeasurementData.tsx new file mode 100644 index 00000000..16576172 --- /dev/null +++ b/src/lib/components/products/ProductMeasurementData.tsx @@ -0,0 +1,201 @@ +import { + Box, + Button, + Checkbox, + Collapse, + Container, + Flex, + HStack, + Heading, + Input, + Spinner, + Text, + Tooltip, + useColorMode, + useColorModeValue +} from "@chakra-ui/react" +import {ChangeEvent, useState} from "react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {FiCheck, FiEdit, FiMinus, FiPlus, FiX} from "react-icons/fi" +import {Inventory, Product, Products, RequiredDeep} from "ordercloud-javascript-sdk" + +import BrandedBox from "../branding/BrandedBox" +import BrandedSpinner from "../branding/BrandedSpinner" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductMeasurementData({composedProduct, setComposedProduct}: ProductDataProps) { + const [isEditingBasicData, setIsEditingBasicData] = useState(false) + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [expanded, setExpanded] = useState(true) + const [isLoading, setIsLoading] = useState(false) + const [formValues, setFormValues] = useState({ + shipWeight: composedProduct?.Product?.ShipWeight, + shipHeight: composedProduct?.Product?.ShipHeight, + shipLength: composedProduct?.Product?.ShipLength, + shipWidth: composedProduct?.Product?.ShipWidth + }) + + const handleNumberInputChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({ + ...v, + [fieldKey]: e.target.value == "" ? 0 : e.target.value + })) + } + + const onEditClicked = (e) => { + setFormValues((v) => ({ + ...v, + ["shipWeight"]: composedProduct?.Product?.ShipWeight, + ["shipHeight"]: composedProduct?.Product?.ShipHeight, + ["shipLength"]: composedProduct?.Product?.ShipLength, + ["shipWidth"]: composedProduct?.Product?.ShipWidth + })) + setIsEditingBasicData(true) + setExpanded(true) + } + + const onAbortClicked = (e) => { + setFormValues((v) => ({ + ...v, + ["shipWeight"]: composedProduct?.Product?.ShipWeight, + ["shipHeight"]: composedProduct?.Product?.ShipHeight, + ["shipLength"]: composedProduct?.Product?.ShipLength, + ["shipWidth"]: composedProduct?.Product?.ShipWidth + })) + setIsEditingBasicData(false) + } + + const onSaveClicked = async (e) => { + setIsLoading(true) + const patchedProduct: Product = { + Name: composedProduct?.Product?.Name, + ShipHeight: formValues?.shipHeight, + ShipLength: formValues.shipLength, + ShipWeight: formValues.shipWeight, + ShipWidth: formValues.shipWidth + } + await Products.Patch(composedProduct?.Product?.ID, patchedProduct) + + // Hack to ensure Data are loaded before showing -> AWAIT is not enough + setTimeout(async () => { + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setTimeout(() => { + setIsEditingBasicData(false) + setIsLoading(false) + }, 2000) + }, 4000) + } + + return ( + <> + <> + {(!composedProduct?.Product || isLoading) && expanded ? ( + + Updating... + + ) : ( + <> + + Shipping Dimensions + + + + + Ship Weight: + {isEditingBasicData ? ( + + ) : ( + + {composedProduct?.Product?.ShipWeight ?? "Not set"} + + )} + + + + Ship Height: + + {isEditingBasicData ? ( + + ) : ( + + {composedProduct?.Product?.ShipHeight ?? "Not set"} + + )} + + + + Ship Length: + {isEditingBasicData ? ( + + ) : ( + + {composedProduct?.Product?.ShipLength ?? "Not set"} + + )} + + + + + Ship Width: + + {isEditingBasicData ? ( + + ) : ( + + {composedProduct?.Product?.ShipWidth ?? "Not set"} + + )} + + + + + )} + + {isEditingBasicData ? ( + + + + + + + + + ) : ( + + + + + + )} + + ) +} diff --git a/src/lib/components/products/ProductMediaInformation.tsx b/src/lib/components/products/ProductMediaInformation.tsx new file mode 100644 index 00000000..ac7b85ff --- /dev/null +++ b/src/lib/components/products/ProductMediaInformation.tsx @@ -0,0 +1,257 @@ +import { + useColorModeValue, + Heading, + Box, + Text, + Image, + Button, + HStack, + Tooltip, + Input, + Collapse, + Center, + VStack +} from "@chakra-ui/react" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {ProductXPs, XpImage} from "lib/types/ProductXPs" +import {Product, Products} from "ordercloud-javascript-sdk" +import {ChangeEvent, useState} from "react" +import {FiCheck, FiX, FiEdit, FiPlus, FiMinus} from "react-icons/fi" +import BrandedBox from "../branding/BrandedBox" +import BrandedSpinner from "../branding/BrandedSpinner" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductMediaInformation({composedProduct, setComposedProduct}: ProductDataProps) { + const [isEditingBasicData, setIsEditingBasicData] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [formValues, setFormValues] = useState({ + images: composedProduct?.Product?.xp?.Images + }) + const [selectedImage, setSelectedImage] = useState(0) + const [expanded, setExpanded] = useState(true) + + const onEditClicked = (e) => { + e.preventDefault() + setFormValues((v) => ({ + ...v, + ["images"]: composedProduct?.Product?.xp?.Images ?? [] + })) + setIsEditingBasicData(true) + setExpanded(true) + } + + const onAbortClicked = (e) => { + e.preventDefault() + setIsEditingBasicData(false) + } + + const handleInputChange = (fieldKey: number) => (e: ChangeEvent) => { + var newVal = e.target.value + var emptyVal = null + var tmpImages = [...formValues.images] + var tmpImage: XpImage = { + Url: newVal, + ThumbnailUrl: newVal + } + + tmpImages[fieldKey] = tmpImage + + setFormValues((v) => ({ + ...v, + ["images"]: tmpImages + })) + } + + const onDeleteProductImageClicked = (url: string) => async (e) => { + setIsLoading(true) + var tmpImages = [...formValues.images] + tmpImages = tmpImages.filter((element) => element.Url != url) + setFormValues((v) => ({ + ...v, + ["images"]: tmpImages + })) + + setIsLoading(false) + } + + const onNewProductImageClicked = async (e) => { + setIsLoading(true) + var tmpImages: XpImage[] = [] + if (formValues.images) { + tmpImages = [...formValues.images] + } + + var tmpImage: XpImage = { + Url: "", + ThumbnailUrl: "" + } + + tmpImages.push(tmpImage) + setFormValues((v) => ({ + ...v, + ["images"]: tmpImages + })) + + setIsLoading(false) + } + + const onProductSave = async () => { + setIsLoading(true) + const images: XpImage[] = [] + formValues.images.map((item) => { + const xpImage: XpImage = { + Url: item.Url, + ThumbnailUrl: item.ThumbnailUrl + } + images.push(xpImage) + }) + // For now focus on first image in list + if (images.length == 0) { + const xpImage: XpImage = { + Url: formValues.images[0]?.Url ?? "", + ThumbnailUrl: formValues.images[0]?.ThumbnailUrl ?? "" + } + images.push(xpImage) + } + + const newProduct: Product = { + Name: composedProduct?.Product?.Name, + xp: { + Name: "Test", + Images: images + } + } + + await Products.Patch(composedProduct?.Product?.ID, newProduct) + + // Hack to ensure Data are loaded before showing -> AWAIT is not enough + setTimeout(async () => { + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setTimeout(() => { + setIsEditingBasicData(false) + setIsLoading(false) + }, 1000) + }, 4500) + } + + return ( + <> + Media + + {(isLoading || !composedProduct?.Product) && expanded ? ( + + Updating... + + ) : ( + <> + + + <> + Images: + {formValues?.images?.map((image, key) => { + return isEditingBasicData ? ( + + {key + 1} + + + + + + ) : ( + <> + ) + })}{" "} + {!isEditingBasicData ? ( + {"Product + ) : ( + <> + )} + + {composedProduct?.Product?.xp?.Images?.map((image, key) => { + return !isEditingBasicData ? ( + + {key + 1} + + {(image?.Url ?? "") == "" ? ( + + <>No Image + + ) : ( + <> + {"Product { + setSelectedImage(key) + }} + /> + + )} + + ) : ( + <> + ) + })} + + {isEditingBasicData && formValues?.images[formValues?.images?.length - 1]?.Url != "" ? ( + + +
    + +
    +
    +
    + ) : ( + <> + )} + +
    +
    + + )} + {isEditingBasicData ? ( + + + + + + + + + ) : ( + + + + + + )} + + ) +} diff --git a/src/lib/components/products/ProductPriceScheduleAssignments.tsx b/src/lib/components/products/ProductPriceScheduleAssignments.tsx new file mode 100644 index 00000000..17dd1e9a --- /dev/null +++ b/src/lib/components/products/ProductPriceScheduleAssignments.tsx @@ -0,0 +1,543 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + FormControl, + HStack, + Heading, + Input, + ListItem, + Tbody, + Td, + Text, + Th, + Thead, + Tooltip, + Tr, + UnorderedList, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import { + Buyer, + Buyers, + PriceSchedule, + PriceSchedules, + ProductAssignment, + Products, + UserGroup, + UserGroups +} from "ordercloud-javascript-sdk" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {useEffect, useState} from "react" +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" +import React from "react" +import {priceHelper} from "lib/utils" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductPriceScheduleAssignments({composedProduct, setComposedProduct}: ProductDataProps) { + const {isOpen, onOpen, onClose} = useDisclosure() + const [isLoading, setIsLoading] = useState(false) + const [expanded, setExpanded] = useState(false) + const color = useColorModeValue("textColor.900", "textColor.100") + const [priceScheduleAssignments, setPriceScheduleAssignments] = useState(null) + const [defaultPriceScheduleAssignment, setDefaultPriceScheduleAssignment] = useState(null) + const cancelRef = React.useRef() + const [newPriceScheduleAssignment, setNewPriceScheduleAssignment] = useState({ + priceSchedule: "", + buyerGroup: "", + userGroup: "" + }) + const [isLinking, setIsLinking] = useState(false) + const [availablePriceSchedule, setAvailablePriceSchedules] = useState(null) + const [availableBuyers, setAvailableBuyers] = useState(null) + const [availableUsergroups, setAvailableUsergroups] = useState(null) + const [isChosen, setIsChosen] = useState({ + priceSchedule: false, + buyerGroup: false + }) + + interface PriceScheduleWithAssignment { + priceSchedule: PriceSchedule + assignment: ProductAssignment + } + + useEffect(() => { + async function GetProductCatalogAssignments() { + if (composedProduct?.Product) { + let priceSchedules: PriceScheduleWithAssignment[] = [] + const assignments = await Products.ListAssignments({ + productID: composedProduct?.Product?.ID + }) + + if (composedProduct?.Product?.DefaultPriceScheduleID) { + const defaultPriceSchedule = await PriceSchedules.Get(composedProduct?.Product?.DefaultPriceScheduleID) + setDefaultPriceScheduleAssignment(defaultPriceSchedule) + } + + await Promise.all( + assignments.Items.map(async (index) => { + var priceSchedule = await PriceSchedules.Get(index.PriceScheduleID) + const priceScheduleWithAssignment: PriceScheduleWithAssignment = { + assignment: index, + priceSchedule: priceSchedule + } + priceSchedules.push(priceScheduleWithAssignment) + }) + ) + + setPriceScheduleAssignments(priceSchedules) + } + } + GetProductCatalogAssignments() + }, [composedProduct]) + + const onPriceScheduleAssignmentRemove = async (e) => { + setIsLoading(true) + e.preventDefault() + const buyerId = e.currentTarget.dataset.buyerid + const userGroupId = e.currentTarget.dataset.usergroupid + await Products.DeleteAssignment(composedProduct?.Product?.ID, buyerId, { + userGroupID: userGroupId + }) + + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsLoading(false) + } + + const onPriceScheduleLink = async (e) => { + setIsLinking(true) + e.preventDefault() + const specProductAssignment: ProductAssignment = { + ProductID: composedProduct?.Product?.ID, + PriceScheduleID: newPriceScheduleAssignment.priceSchedule, + BuyerID: newPriceScheduleAssignment.buyerGroup, + UserGroupID: newPriceScheduleAssignment.userGroup + } + + await Products.SaveAssignment(specProductAssignment) + + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsLinking(false) + setNewPriceScheduleAssignment((v) => ({ + ...v, + ["priceSchedule"]: "", + ["buyerGroup"]: "", + ["userGroup"]: "" + })) + setAvailablePriceSchedules(null) + setAvailableBuyers(null) + setExpanded(true) + setIsChosen((v) => ({ + ...v, + ["priceSchedule"]: false, + ["buyerGroup"]: false + })) + onClose() + } + + const onAvailableReferenceClick = (fieldKey: string) => (e) => { + e.preventDefault() + const chosenReference = e.currentTarget.dataset.id + setNewPriceScheduleAssignment((v) => ({ + ...v, + [fieldKey]: chosenReference + })) + setIsChosen((v) => ({ + ...v, + [fieldKey]: true + })) + } + + const onLinkInputFocused = (fieldKey: string) => (e) => { + const newValue = e.target.value + executeSearch(fieldKey, newValue, false) + } + + const onLinkInputChanged = (fieldKey: string) => (e) => { + const newValue = e.target.value + executeSearch(fieldKey, newValue, true) + } + + const executeSearch = (fieldKey: string, fieldValue: string, resetOthers: boolean) => { + const newValue = fieldValue + if (resetOthers) { + setIsChosen((v) => ({ + ...v, + [fieldKey]: false + })) + } + + setNewPriceScheduleAssignment((v) => ({...v, [fieldKey]: newValue})) + if (fieldKey == "priceSchedule") { + if (resetOthers) { + setIsChosen((v) => ({ + ...v, + ["buyerGroup"]: false, + ["userGroup"]: false + })) + setNewPriceScheduleAssignment((v) => ({ + ...v, + ["buyerGroup"]: "", + ["userGroup"]: "" + })) + } + + PriceSchedules.List({ + searchOn: ["Name", "ID"], + search: newValue + }).then((innerPriceSchedules) => { + const priceScheduleIds = priceScheduleAssignments.map((item) => { + return item.priceSchedule.ID + }) + const filteredPriceSchedules = innerPriceSchedules?.Items?.filter( + (innerPriceSchedule) => !priceScheduleIds.includes(innerPriceSchedule.ID) + ) + setAvailablePriceSchedules(filteredPriceSchedules) + setAvailableUsergroups(null) + setAvailableBuyers(null) + }) + } else if (fieldKey == "buyerGroup") { + if (resetOthers) { + setIsChosen((v) => ({ + ...v, + ["userGroup"]: false + })) + setNewPriceScheduleAssignment((v) => ({ + ...v, + ["userGroup"]: "" + })) + } + + Buyers.List({ + searchOn: ["Name", "ID"], + search: newValue + }).then((innerBuyers) => { + const buyerIds = priceScheduleAssignments + .filter((item) => { + item.priceSchedule.ID == newPriceScheduleAssignment.priceSchedule + }) + .map((item) => { + return item.assignment.BuyerID + }) + const filteredAvailableBuyer = innerBuyers.Items.filter((buyer) => !buyerIds.includes(buyer.ID)) + setAvailableBuyers(filteredAvailableBuyer) + setAvailableUsergroups(null) + setAvailablePriceSchedules(null) + }) + } else if (fieldKey == "userGroup") { + UserGroups.List(newPriceScheduleAssignment.buyerGroup, { + searchOn: ["Name", "ID"], + search: newValue + }).then((innerUserGroups) => { + const userGroupIds = priceScheduleAssignments + .filter((item) => { + item.priceSchedule.ID == newPriceScheduleAssignment.priceSchedule && + item.assignment.BuyerID == newPriceScheduleAssignment.buyerGroup + }) + .map((item) => { + return item.assignment.UserGroupID + }) + const filteredAvailableUserGroups = innerUserGroups.Items.filter( + (userGroup) => !userGroupIds.includes(userGroup.ID) + ) + setAvailableUsergroups(filteredAvailableUserGroups) + setAvailableBuyers(null) + setAvailablePriceSchedules(null) + }) + } + } + + return ( + <> + <> + Price Schedules{" "} + {(isLoading || !composedProduct?.Product) && expanded ? ( + + Updating... + + ) : ( + <> + + {(priceScheduleAssignments?.length ?? 0) == 0 && defaultPriceScheduleAssignment == null ? ( + <>No Price Schedules + ) : ( + + + + ID + Name + Price Breaks + Buyer Group + User Group + Action + + + + {defaultPriceScheduleAssignment != null ? ( + + {defaultPriceScheduleAssignment?.ID} + {defaultPriceScheduleAssignment?.Name} + + {" "} +
      + {defaultPriceScheduleAssignment?.PriceBreaks?.map((item, index) => { + return ( +
    • + Quantity: {item.Quantity}
      + Price: {priceHelper.formatPrice(item.Price)}
      {" "} + {priceHelper.formatPrice(item.SalePrice) + ? "Sales Price: " + priceHelper.formatPrice(item.SalePrice) + : null} +
    • + ) + })} +
    + + + + DEFAULT + + ) : null} + + {priceScheduleAssignments?.map((item, index) => { + return ( + + {item.priceSchedule.ID} + {item.priceSchedule.Name} + + {" "} +
      + {item?.priceSchedule.PriceBreaks?.map((item, index) => { + return ( +
    • + Quantity: {item.Quantity}
      Price: {priceHelper.formatPrice(item.Price)}
      + {priceHelper.formatPrice(item.SalePrice) + ? "Sales Price: " + priceHelper.formatPrice(item.SalePrice) + : null} +
    • + ) + })} +
    + + {item.assignment.BuyerID} + {item.assignment.UserGroupID} + + {" "} + + + + + + ) + })} + +
    + )} +
    + + )} + + + + + + + + + + + {isLinking ? ( + + Linking... + + ) : ( + <> + + Link Price Schedule to Product + + + Please fill in the following fields to link a Price Schedule + + + + + + + + + + {(availablePriceSchedule?.length ?? 0) > 0 ? ( + <> + + + Available Price Schedules (Please choose...) + + + {availablePriceSchedule.map((element, key) => ( + + + Name: {element.Name} | ID: + {element.ID} + + + ))} + + + + ) : null} + {(availableBuyers?.length ?? 0) > 0 ? ( + <> + + + Available Buyers (Please choose...) + + + {availableBuyers.map((element, key) => ( + + + Name: {element.Name} | ID: + {element.ID} + + + ))} + + + + ) : null} + {(availableUsergroups?.length ?? 0) > 0 ? ( + <> + + + Available User Groups (Please choose...) + + + {availableUsergroups.map((element, key) => ( + + + Name: {element.Name} | ID: + {element.ID} + + + ))} + + + + ) : null} + + + {!isChosen.priceSchedule ? ( + Please start entering a price schedule ID and choose from the search + ) : !isChosen.buyerGroup ? ( + Please now start entering a buyer id and choose from the search + ) : ( + + Please optionally start entering an usergroup id and choose from the search if wanted + + )} + + + + + + )} + + + + + ) +} diff --git a/src/lib/components/products/ProductSearch.tsx b/src/lib/components/products/ProductSearch.tsx new file mode 100644 index 00000000..bc761613 --- /dev/null +++ b/src/lib/components/products/ProductSearch.tsx @@ -0,0 +1,784 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + Checkbox, + CheckboxGroup, + Divider, + FormControl, + FormLabel, + HStack, + Icon, + IconButton, + Input, + InputGroup, + InputLeftElement, + Menu, + MenuButton, + MenuItem, + MenuList, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Select, + Slider, + SliderFilledTrack, + SliderMark, + SliderThumb, + SliderTrack, + Spacer, + Spinner, + Stack, + Text, + Tooltip, + VStack, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import {ChangeEvent, useEffect, useRef, useState} from "react" +import {ChevronDownIcon} from "@chakra-ui/icons" +import {FiChevronDown, FiChevronUp} from "react-icons/fi" +import {HiOutlineViewGrid, HiOutlineViewList} from "react-icons/hi" +import {Product, Products} from "ordercloud-javascript-sdk" +import {AiOutlineSearch} from "react-icons/ai" +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" +import {CalculateEditorialProcess} from "./EditorialProgressBar" +import Card from "../card/Card" +import {NextSeo} from "next-seo" +import ProductGrid from "./ProductGrid" +import ProductList from "./ProductList" +import {ProductListOptions} from "../../services/ordercloud.service" +import {ProductXPs} from "lib/types/ProductXPs" +import {promotionsService} from "lib/api" +import {useErrorToast} from "lib/hooks/useToast" +import {Link} from "../navigation/Link" + +interface ProductSearchProps { + query: string +} + +export default function ProductSearch({query}: ProductSearchProps) { + const [selectedPromotion, setselectedPromotion] = useState("") + const [promotions, setPromotions] = useState([]) + const optionsSearchType = "ExactPhrasePrefix" + const [optionsSearch, setOptionsSearch] = useState("") + const [optionsSortBy, setOptionsSortBy] = useState("name") + const errorToast = useErrorToast() + const [products, setProducts] = useState[]>(null) + const [componentProducts, setComponentProducts] = useState[]>(null) + const [isLoading, setIsLoading] = useState(true) + const sliderColor = useColorModeValue("brand.400", "brand.600") + const [editorialProgressFilter, setEditorialProgressFilter] = useState(100) + const [sortBy, setSortBy] = useState("name") + const [sortingChanging, setSortingChanging] = useState(false) + const [sortDesc, setSortDesc] = useState(false) + const labelStyles = { + mt: "2", + ml: "-2.5", + fontSize: "sm" + } + const [toggleViewMode, setToggleViewMode] = useState(false) + + const [isBulkImportDialogOpen, setBulkImportDialogOpen] = useState(false) + const [isExportCSVDialogOpen, setExportCSVDialogOpen] = useState(false) + const [isPromotionDialogOpen, setPromotionDialogOpen] = useState(false) + const [loading, setLoading] = useState(false) + const cancelRef = useRef() + + const requestExportCSV = () => {} + const requestImportCSV = () => {} + + useEffect(() => { + async function GetProducts() { + const options: ProductListOptions = {} + options.search = optionsSearch + options.searchOn = ["Name", "Description", "ID"] + options.searchType = optionsSearchType + options.sortBy = [optionsSortBy] + options.pageSize = 100 + var productList = await Products.List(options) + let productItems = productList.Items + setComponentProducts(productItems) + setProducts(productItems) + setIsLoading(false) + const promotionsList = await promotionsService.list() + let promotionItems = promotionsList.Items + setPromotions(promotionItems) + } + + GetProducts() + }, [optionsSearch, optionsSearchType, optionsSortBy]) + + const [searchQuery, setSearchQuery] = useState(query) + const {isOpen: isOpenAddProduct, onOpen: onOpenAddProduct, onClose: onCloseAddProduct} = useDisclosure() + const { + isOpen: isOpenMassEditProducts, + onOpen: onOpenselectedProductIds, + onClose: onCloseMassEditProducts + } = useDisclosure() + const [isAdding, setIsAdding] = useState(false) + const [isMassEditing, setIsMassEditing] = useState(false) + const [selectedProductIds, setSelectedProductIds] = useState([]) + const [formValues, setFormValues] = useState({ + id: "", + name: "", + description: "", + isActive: false, + isInactive: false + }) + + const onSearchClicked = async () => { + setOptionsSortBy("name") + setSortBy("name") + setEditorialProgressFilter(100) + setOptionsSearch(searchQuery) + } + + // TODO Add more properties in Add handling + const onProductAdd = async (e) => { + if (formValues.id == "" || formValues.name == "") { + errorToast({ + description: "Please fill out ID and NAME to add the product" + }) + return + } + + setIsAdding(true) + e.preventDefault() + const newProduct: Product = { + Name: formValues.name, + Description: formValues.description, + ID: formValues.id, + Active: formValues.isActive + } + await Products.Create(newProduct) + + setFormValues((v) => ({ + ...v, + ["isActive"]: false, + ["isInactive"]: false, + ["name"]: "", + ["id"]: "", + ["description"]: "" + })) + + setTimeout(() => { + onCloseAddProduct() + //setReload(true) + setIsAdding(false) + }, 5000) + } + + const handleInputChange = (fieldKey: string) => (e: ChangeEvent) => { + setFormValues((v) => ({...v, [fieldKey]: e.target.value})) + } + + const handleCheckboxChange = (fieldKey: string) => (e: ChangeEvent) => { + if (fieldKey == "isActive" && formValues["isInactive"]) { + setFormValues((v) => ({...v, ["isInactive"]: false})) + } else if (fieldKey == "isInactive" && formValues["isActive"]) { + setFormValues((v) => ({...v, ["isActive"]: false})) + } + setFormValues((v) => ({...v, [fieldKey]: !!e.target.checked})) + } + + const handleToggleSelectAllProducts = () => { + if (products.length === selectedProductIds.length) { + setSelectedProductIds([]) + } else { + setSelectedProductIds(products.map((p) => p.ID)) + } + } + + const onResetSearch = (e) => { + //console.log("onResetSearch") + setSearchQuery("") + setOptionsSortBy("name") + setSortBy("name") + setSelectedProductIds([]) + //setReload(true) + setEditorialProgressFilter(100) + setSortDesc(false) + } + + const onExecuteMassEdit = async () => { + setIsMassEditing(true) + var activate = formValues.isActive + var deactivate = formValues.isInactive + var newActivationStatus = activate ? true : deactivate ? false : null + if (newActivationStatus == null) { + errorToast({ + title: "No Activation Status set", + description: "Please choose at least 1 activation status" + }) + setIsMassEditing(false) + return + } + + const requests = selectedProductIds.map((productId) => Products.Patch(productId, {Active: newActivationStatus})) + const responses = await Promise.all(requests) + + const updatedProducts = products.map((p) => { + const responseProduct = responses.find((r) => r.ID === p.ID) + if (responseProduct) { + p.Active = responseProduct.Active + } + return p + }) + setProducts(updatedProducts) + + const updatedComponentProducts = componentProducts.map((p) => { + const responseProduct = responses.find((r) => r.ID === p.ID) + if (responseProduct) { + p.Active = responseProduct.Active + } + return p + }) + setComponentProducts(updatedComponentProducts) + + setOptionsSearch(searchQuery) + setIsMassEditing(false) + setSelectedProductIds([]) + onCloseMassEditProducts() + setFormValues((v) => ({ + ...v, + ["isActive"]: false, + ["isInactive"]: false, + ["name"]: "", + ["id"]: "", + ["description"]: "" + })) + setEditorialProgressFilter(100) + } + + const handleProductSelected = (productId: string, selected: boolean) => { + if (selected) { + const newselectedProductIds = [...selectedProductIds, productId] + setSelectedProductIds(newselectedProductIds) + } else { + const newselectedProductIds = selectedProductIds.filter((pID) => pID !== productId) + setSelectedProductIds(newselectedProductIds) + } + } + + const onMassEditOpenClicked = async (e) => { + if (selectedProductIds.length == 0) { + errorToast({ + title: "No Products selected", + description: "Please select at least 1 Product for mass editing" + }) + } else { + onOpenselectedProductIds() + } + } + + const onSortByNameClicked = (newVal: string) => { + setSortingChanging(true) + if (newVal == "editorialProcess") { + var tmpComponentProducts = [...componentProducts] + var newProducts = tmpComponentProducts.sort((a, b) => CalculateEditorialProcess(a) - CalculateEditorialProcess(b)) + setComponentProducts(newProducts) + } else if (newVal == "!editorialProcess") { + var tmpComponentProducts = [...componentProducts] + var newProducts = tmpComponentProducts.sort((a, b) => CalculateEditorialProcess(b) - CalculateEditorialProcess(a)) + setComponentProducts(newProducts) + } else { + setOptionsSearch(searchQuery) + if (newVal != "") { + setOptionsSortBy(newVal) + } + } + setSortBy(newVal) + setSortDesc(newVal.substring(0, 1) == "!") + setSortingChanging(false) + } + + const handleSelectChange = (e: ChangeEvent) => { + onSortByNameClicked(e.target.value) + } + + const onEditorialProgressFilterChanged = async (e) => { + var newProducts = products.filter((element) => { + return CalculateEditorialProcess(element) <= e + }) + + if (optionsSortBy == "editorialProcess") { + var tmpComponentProducts = [...newProducts] + newProducts = tmpComponentProducts.sort((a, b) => CalculateEditorialProcess(a) - CalculateEditorialProcess(b)) + } else if (optionsSortBy == "!editorialProcess") { + var tmpComponentProducts = [...newProducts] + newProducts = tmpComponentProducts.sort((a, b) => CalculateEditorialProcess(b) - CalculateEditorialProcess(a)) + } + + setComponentProducts(newProducts) + } + + return ( + <> + {componentProducts ? ( + + + + {isLoading && !sortingChanging ? ( + + ) : ( + <> + + + + + + + + + Filters + + + + + + + setEditorialProgressFilter(val)} + onChangeEnd={onEditorialProgressFilterChanged} + min={0} + max={100} + step={1} + > + + 0% + + + 25% + + + 50% + + + 75% + + + 100% + + + {editorialProgressFilter}% + + + + + + + + Filter by Editorial Progress... + + + + Product Status + + + + Completed + + + Awaiting Approval + + + Canceled + + + Declined + + + Open + + + + + + + + : } + onClick={() => { + setSortDesc(!sortDesc) + sortBy.substring(0, 1) == "!" + ? setSortBy(sortBy.substring(1)) + : setSortBy("!" + sortBy) + optionsSortBy.substring(0, 1) == "!" + ? setOptionsSortBy(optionsSortBy.substring(1)) + : setOptionsSortBy("!" + optionsSortBy) + }} + float="right" + /> + + + + + + + + + + + + + + + + Total Products: {componentProducts.length} + + + + + setToggleViewMode(false)} + fontSize="36px" + color="gray.200" + cursor="pointer" + /> + + + setToggleViewMode(true)} + fontSize="36px" + color="gray.200" + cursor="pointer" + /> + + + + + + + setSearchQuery(e.target.value)} + onKeyPress={(e) => { + if (e.key === "Enter") { + onSearchClicked() + } + }} + /> + + + + + + {toggleViewMode ? ( + onSortByNameClicked(columnName)} + sortBy={sortBy} + /> + ) : ( + + )} + + + + {componentProducts.length} out of {componentProducts.length} + Products + + + + + )} + + ) : ( + + )} + + + + + {isAdding ? ( + + Adding... + + ) : ( + <> + Add a new Product + + + + ID* + + + + + Name* + + + + + Description + + + + + Is Active + + + + + + + + + + + + )} + + + + + + + {isMassEditing ? ( + + MassEditing... + + ) : ( + <> + Mass Edit Products + + + You have selected {selectedProductIds.length} Products + + Activate + + + + Deactivate + + + + + + + + + + + + )} + + + setExportCSVDialogOpen(false)} + leastDestructiveRef={cancelRef} + > + + + + Export Selected Products to CSV + + + + Export the selected products to a CSV, once the export button is clicked behind the scenes a job will be + kicked off to create the csv and then will automatically download to your downloads folder in the + browser. + + + + + + + + + + + + + setBulkImportDialogOpen(false)} + leastDestructiveRef={cancelRef} + > + + + + Bulk Import Products + + + + Bulk import products from an excel or csv file, once the upload button is clicked behind the scenes a + job will be kicked off load each of the products included in your files, once it has completed you will + see them appear in your search. + + + + + + + + + + + + + setPromotionDialogOpen(false)} + leastDestructiveRef={cancelRef} + > + + + + Attach a Promotion + + + + Select a promotion from the dropdown to assign to the previously selected products. + + + + + + + + + + + + + + ) +} diff --git a/src/lib/components/products/ProductSpecs.tsx b/src/lib/components/products/ProductSpecs.tsx new file mode 100644 index 00000000..7931829c --- /dev/null +++ b/src/lib/components/products/ProductSpecs.tsx @@ -0,0 +1,309 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + Checkbox, + FormControl, + HStack, + Heading, + Input, + ListItem, + Tbody, + Td, + Text, + Th, + Thead, + Tooltip, + Tr, + UnorderedList, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {Products, Spec, SpecProductAssignment, Specs} from "ordercloud-javascript-sdk" +import BrandedBox from "../branding/BrandedBox" +import BrandedTable from "../branding/BrandedTable" +import React from "react" +import {useState} from "react" +import BrandedSpinner from "../branding/BrandedSpinner" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductSpecs({composedProduct, setComposedProduct}: ProductDataProps) { + const color = useColorModeValue("textColor.900", "textColor.100") + const bg = useColorModeValue("brand.500", "brand.500") + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [expanded, setExpanded] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const {isOpen, onOpen, onClose} = useDisclosure() + const cancelRef = React.useRef() + const [newSpecifaction, setNewSpecification] = useState("") + const [isLinking, setIsLinking] = useState(false) + const [availableSpecs, setAvailableSpecs] = useState[]>(null) + const [isSpecChosen, setIsSpecChosen] = useState(false) + const [regenerateVariants, setRegenerateVariants] = useState(false) + + const onRemoveSpecification = async (e) => { + e.preventDefault() + setIsLoading(true) + const specId = e.currentTarget.dataset.id + await Specs.DeleteProductAssignment(specId, composedProduct?.Product?.ID) + + var targetSpec = composedProduct?.Specs?.find((innerSpec) => innerSpec.ID == specId) + if (targetSpec.DefinesVariant) { + // TODO: ASK in Dialog if Variants shall be regenerated and how? + // In case a variant spec has been deleted, all the variants have to be regenerated + await Products.GenerateVariants(composedProduct?.Product?.ID, { + overwriteExisting: true + }) + } + + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsLoading(false) + } + + const onSpecificationLink = async (e) => { + setIsLinking(true) + e.preventDefault() + const specProductAssignment: SpecProductAssignment = { + ProductID: composedProduct?.Product?.ID, + SpecID: newSpecifaction + } + + await Specs.SaveProductAssignment(specProductAssignment) + var targetSpec = await Specs.Get(newSpecifaction) + if (targetSpec.DefinesVariant && regenerateVariants) { + // TODO: ASK in Dialog if Variants shall be regenerated and how? + // In case a variant spec has been deleted, all the variants have to be regenerated + await Products.GenerateVariants(composedProduct?.Product?.ID, { + overwriteExisting: true + }) + } + + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsLinking(false) + setNewSpecification("") + setAvailableSpecs(null) + setExpanded(true) + onClose() + } + + const onAvailableSpecClick = (e) => { + e.preventDefault() + const chosenSpec = e.currentTarget.dataset.id + setNewSpecification(chosenSpec) + setIsSpecChosen(true) + } + + const onSpecificationLinkInputChanged = (e) => { + e.preventDefault() + setIsSpecChosen(false) + setNewSpecification(e.target.value) + const availableSpecs = Specs.List({ + searchOn: ["Name", "ID"], + search: e.target.value + }).then((innerSpecs) => { + const specIds = composedProduct?.Specs?.map((item) => { + return item.ID + }) + const filteredSpecs = innerSpecs.Items.filter((innerSpec) => !specIds.includes(innerSpec.ID)) + setAvailableSpecs(filteredSpecs) + }) + } + + return ( + <> + <> + Specs{" "} + {(isLoading || !composedProduct?.Product) && expanded ? ( + + Updating... + + ) : ( + <> + + {(composedProduct?.Specs?.length ?? 0) == 0 ? ( + <>No Specs + ) : ( + + + + ID + Name + Number Options + Defines Variant + Action + + + + {composedProduct?.Specs?.map((item, index) => { + return ( + + {item.ID} + {item.Name} + {item.OptionCount} + + {item.DefinesVariant ?? false ? ( + + ) : ( + + )} + + +
      + {item?.Options?.map((item, index) => { + return ( +
    • + {item.ID} | {item.Value} +
    • + ) + })} +
    + + + {" "} + + + + + + ) + })} + +
    + )} +
    + + )} + + + + + + + + + + {isLinking ? ( + + Linking... + + ) : ( + <> + + Link Specification to Product + + + Please choose Specification to link + + + + + { + setRegenerateVariants(e.target.checked) + }} + size="lg" + colorScheme={"purple"} + /> + + Autoregenerate Variants? + + + {(availableSpecs?.length ?? 0) > 0 ? ( + <> + + + Available Specs (Please choose...) + + + {availableSpecs.map((element, key) => ( + + + Name: {element.Name} | ID: {element.ID} + + + ))} + + + + ) : null} + + + {isSpecChosen ? null : Please choose from the search results to link a spec} + + + + + + )} + + + + + ) +} diff --git a/src/lib/components/products/ProductSupllier.tsx b/src/lib/components/products/ProductSupllier.tsx new file mode 100644 index 00000000..f53d0f0c --- /dev/null +++ b/src/lib/components/products/ProductSupllier.tsx @@ -0,0 +1,125 @@ +import { + Box, + Button, + HStack, + Heading, + Tbody, + Td, + Th, + Thead, + Tooltip, + Tr, + useColorModeValue, + Switch +} from "@chakra-ui/react" +import {ProductSupplier, Products} from "ordercloud-javascript-sdk" +import React, {useEffect} from "react" +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" +import {ComposedProduct} from "../../services/ordercloud.service" +import {useState} from "react" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductSuppliers({composedProduct, setComposedProduct}: ProductDataProps) { + const color = useColorModeValue("textColor.900", "textColor.100") + const bg = useColorModeValue("brand.500", "brand.500") + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [expanded, setExpanded] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [supplier, setSupplier] = useState(null) + + useEffect(() => { + async function GetProdcutSupplier() { + if (composedProduct?.Product) { + var productSupplier = await Products.ListSuppliers(composedProduct?.Product?.ID) + setSupplier(productSupplier.Items) + } + } + GetProdcutSupplier() + }, [composedProduct]) + + return ( + <> + <> + + Supplier + + + {(isLoading || !composedProduct?.Product) && expanded ? ( + + Updating... + + ) : ( + <> + + {(supplier?.length ?? 0) == 0 ? ( + <>No Supplier + ) : ( + + + + ID + Name + Is Active + All Buyers can Order + Action + + + + {supplier?.map((item, index) => { + return ( + + {item.ID} + {item.Name} + + + + + + + + {" "} + + + + + + ) + })} + + + )} + + + )} + + + + + + + + ) +} diff --git a/src/lib/components/products/ProductVariants.tsx b/src/lib/components/products/ProductVariants.tsx new file mode 100644 index 00000000..c947c64e --- /dev/null +++ b/src/lib/components/products/ProductVariants.tsx @@ -0,0 +1,217 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Box, + Button, + Checkbox, + Collapse, + HStack, + Heading, + Table, + TableContainer, + Tbody, + Td, + Th, + Thead, + Tooltip, + Tr, + useColorMode, + useColorModeValue, + useDisclosure +} from "@chakra-ui/react" +import {CheckIcon, CloseIcon} from "@chakra-ui/icons" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {FiMinus, FiPlus, FiRefreshCw, FiTrash2, FiZap} from "react-icons/fi" +import {Product, Products, RequiredDeep, Variant} from "ordercloud-javascript-sdk" + +import BrandedBox from "../branding/BrandedBox" +import BrandedSpinner from "../branding/BrandedSpinner" +import BrandedTable from "../branding/BrandedTable" +import React from "react" +import {useState} from "react" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductVariants({composedProduct, setComposedProduct}: ProductDataProps) { + const {colorMode, toggleColorMode} = useColorMode() + const color = useColorModeValue("textColor.900", "textColor.100") + const gradient = colorMode === "light" ? "linear(to-t, brand.300, brand.400)" : "linear(to-t, brand.600, brand.500)" + const shadow = "5px 5px 5px #999999" + const okColor = useColorModeValue("okColor.800", "okColor.200") + const errorColor = useColorModeValue("errorColor.800", "errorColor.200") + const [expanded, setExpanded] = useState(false) + const [overwriteExistingVariants, setOverwriteExistingVariants] = useState(false) + const [isGenerating, setIsGenerating] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const {isOpen, onOpen, onClose} = useDisclosure() + const cancelRef = React.useRef() + + const onGenerateVariantsClicked = async (e) => { + setIsGenerating(true) + e.preventDefault() + await Products.GenerateVariants(composedProduct?.Product?.ID, { + overwriteExisting: overwriteExistingVariants + }) + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsGenerating(false) + onClose() + setExpanded(true) + } + + const onVariantStatusChange = async (e) => { + e.preventDefault() + setIsLoading(true) + const variantId = e.currentTarget.dataset.id + let variant = composedProduct?.Variants?.find((element) => element.ID == variantId) + const newVariant: Variant = { + Active: !variant.Active, + ID: variant.ID + } + await Products.PatchVariant(composedProduct?.Product?.ID, variantId, newVariant) + var product = await GetComposedProduct(composedProduct?.Product?.ID) + setComposedProduct(product) + setIsLoading(false) + } + + return ( + <> + {" "} + <> + Variants + {(isLoading || !composedProduct?.Product) && expanded ? ( + + Updating... + + ) : ( + <> + + {composedProduct?.Variants?.length ?? 0 > 0 ? ( + <> + + + + ID + Name + Is Active? + Action + + + + {composedProduct?.Variants ? ( + <> + {composedProduct?.Variants.map((item, index) => { + return ( + + {item.ID} + {item.Name} + + {" "} + {item?.Active ?? false ? ( + + ) : ( + + )} + + + {item?.Active ?? false ? ( + + + + ) : ( + + + + )} + + + ) + })} + + ) : ( + <>No Variants + )} + + + + ) : ( + <>No Variants + )} + + + )} + + + + + + + + + + {isGenerating ? ( + + Generating... + + ) : ( + <> + + Generate Variants + + + Would you like to overwrite existing Variants? + { + setOverwriteExistingVariants(e.target.checked) + }} + /> + + + + + + )} + + + + + ) +} diff --git a/src/lib/components/products/ProductXpCards.tsx b/src/lib/components/products/ProductXpCards.tsx new file mode 100644 index 00000000..0ac37f9a --- /dev/null +++ b/src/lib/components/products/ProductXpCards.tsx @@ -0,0 +1,466 @@ +import { + Heading, + Box, + Text, + Button, + HStack, + Tooltip, + Center, + Modal, + ModalBody, + ModalContent, + ModalHeader, + ModalOverlay, + useDisclosure, + Select, + Input, + Textarea, + CheckboxGroup, + Checkbox +} from "@chakra-ui/react" +import {ComposedProduct, GetComposedProduct} from "../../services/ordercloud.service" +import {ProductXPs} from "lib/types/ProductXPs" +import {Product, Products} from "ordercloud-javascript-sdk" +import {ChangeEvent, useEffect, useState} from "react" +import BrandedSpinner from "../branding/BrandedSpinner" +import TagContainer from "../generic/tagContainer" +import {useErrorToast} from "lib/hooks/useToast" + +type ProductDataProps = { + composedProduct: ComposedProduct + setComposedProduct: React.Dispatch> +} + +export default function ProductXpCards({composedProduct, setComposedProduct}: ProductDataProps) { + const {isOpen: isOpenAddXP, onOpen: onOpenAddXP, onClose: onCloseAddXP} = useDisclosure() + const {isOpen: isOpenEditXP, onOpen: onOpenEditXP, onClose: onCloseEditXP} = useDisclosure() + + const [isAdding, setIsAdding] = useState(false) + const [isEditingBasicData, setIsEditingBasicData] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [formValues, setFormValues] = useState(Object.assign({}, composedProduct?.Product?.xp)) + const [newXpFormName, setNewXpFormName] = useState("") + const [newXpFormType, setNewXpFormType] = useState("text") + const [newXpFormValue, setNewXpFormValue] = useState("") + const [xpsToBeDeleted, setXpsToBeDeleted] = useState([]) + const [expanded, setExpanded] = useState(true) + const [editing, setEditing] = useState("") + //const [editingType, setEditingType] = useState("text") + const errorToast = useErrorToast() + const tags = ["1", "2", "3", "4", "5", "6"] + + useEffect(() => { + setFormValues(Object.assign({}, composedProduct?.Product?.xp)) + }, [composedProduct?.Product?.xp]) + + const onEditClicked = (e) => { + e.preventDefault() + setFormValues(Object.assign({}, composedProduct?.Product?.xp)) + setIsEditingBasicData(true) + setExpanded(true) + } + + const onAbortClicked = (e) => { + e.preventDefault() + setIsDeleting(false) + setIsEditingBasicData(false) + setFormValues(Object.assign({}, composedProduct?.Product?.xp)) + setXpsToBeDeleted([]) + } + + const handleXPChange = (name: string) => { + setEditing(name) + console.log("handleXPChange:formValues[name]:", formValues[name]) + setNewXpFormValue(formValues[name]) + onOpenEditXP() + } + const onEditProductXP = () => { + setIsLoading(true) + //console.log("newXpFormName:", newXpFormName) + //console.log("newXpFormType:", newXpFormType) + console.log("onEditProductXP:newXpFormValue:", newXpFormValue) + console.log("onEditProductXP:editing:", editing) + formValues[editing] = newXpFormValue + onEditProductXPClosed() + setIsLoading(false) + return + } + + const handleInputChange = + (fieldKey: string) => (e: ChangeEvent | ChangeEvent) => { + var newVal = e.target.type == "number" ? Number(e.target.value) : e.target.value + var tmpXPs = formValues + tmpXPs[fieldKey] = newVal + setFormValues(tmpXPs) + } + + const handleEditXP = (e) => { + if (editing.endsWith("###")) { + var strValues = newXpFormValue.toString() + var tempValues = strValues.includes(",") ? strValues.split(",") : [strValues] + console.log("handleEditXP:tempValues", tempValues) + tempValues = tempValues.includes(e.target.value) + ? tempValues.filter((ele) => ele !== e.target.value) + : [...tempValues, e.target.value] + console.log("after handleEditXP:tempValues", tempValues) + console.log("handleEditXP:tempValues.join(',')", tempValues.join(",")) + setNewXpFormValue(tempValues.length > 1 ? tempValues.join(",") : tempValues[0]) + } else setNewXpFormValue(typeof formValues[editing] == "string" ? e.target.value : Number(e.target.value)) + } + const handleNewXPChange = ( + e /* : + | ChangeEvent + | ChangeEvent + | ChangeEvent */ + ) => { + //var newVal = e.target.type == "number" ? Number(e.target.value) : e.target.value + console.log(e.target.name) + console.log(e.target.value) + switch (e.target.name) { + case "name": + setNewXpFormName(e.target.value) + break + case "type": + setNewXpFormType(e.target.value) + break + case "value": + if (newXpFormType == "tag") { + var strValues = newXpFormValue.toString() + var tempValues = strValues.includes(",") ? strValues.split(",") : [strValues] + console.log("handleEditXP:tempValues", tempValues) + tempValues = tempValues.includes(e.target.value) + ? tempValues.filter((ele) => ele !== e.target.value) + : [...tempValues, e.target.value] + console.log("after handleEditXP:tempValues", tempValues) + console.log("handleEditXP:tempValues.join(',')", tempValues.join(",")) + setNewXpFormValue(tempValues.length > 1 ? tempValues.join(",") : tempValues[0]) + } else { + setNewXpFormValue(newXpFormType == "number" ? Number(e.target.value) : e.target.value) + } + + break + default: + return + } + //setNewXPFormValues(tempNewVal) + //console.log(e.target.value) + } + + const onDeleteProductXPClicked = (key: string) => async (e) => { + setIsLoading(true) + setIsDeleting(true) + // console.log("key:" + key) + // console.log("xpsToBeDeleted.includes(key):", xpsToBeDeleted.includes(key)) + // console.log("xpsToBeDeleted.indexOf(key)", xpsToBeDeleted.indexOf(key)) + // console.log( + // "xpsToBeDeleted.filter((thing) => thing !== key)", + // xpsToBeDeleted.filter((thing) => thing !== key) + // ) + const tempDeleted = xpsToBeDeleted.includes(key) + ? xpsToBeDeleted.filter((thing) => thing !== key) + : [...xpsToBeDeleted, key] + setXpsToBeDeleted(tempDeleted) + setIsLoading(false) + //console.log(tempDeleted) + } + + const onNewProductXP = async () => { + //console.log(formValues[newXpFormName]) + const TempXpFormName = newXpFormType == "tag" ? newXpFormName + "###" : newXpFormName + console.log("TempXpFormName:", TempXpFormName) + if (formValues[TempXpFormName] !== undefined) { + errorToast({ + title: "Validation Error", + description: "Extended property of that name already exists" + }) + return + } + setIsLoading(true) + //console.log("newXpFormName:", newXpFormName) + //console.log("newXpFormType:", newXpFormType) + //console.log("newXpFormValue:", newXpFormValue) + + formValues[TempXpFormName] = newXpFormValue + onNewProductXPClosed() + setIsLoading(false) + } + const onNewProductXPClosed = async () => { + //console.log("close function") + setNewXpFormName("") + setNewXpFormType("text") + setNewXpFormValue("") + + onCloseAddXP() + } + const onEditProductXPClosed = async () => { + //console.log("close function") + //setNewXpFormName("") + //setNewXpFormType("text") + //setNewXpFormValue("") + setEditing("") + onCloseEditXP() + } + const renderCurrentSelection = () => { + switch (newXpFormType) { + case "text": + return ( + <> + Value: + + + ) + case "number": + return ( + <> + Value: + + + ) + case "tag": + return ( + <> + Value: + + {tags.map((x, key) => { + return ( + + {x} + + ) + })} + + + ) + default: + return "" + } + } + const renderCurrentEditing = () => { + const editingType = editing.endsWith("###") ? "tag" : typeof formValues[editing] == "string" ? "text" : "number" + console.log("editingType:", editingType) + console.log("renderCurrentEditing:formValues[editing].length", formValues[editing]?.length) + switch (editingType) { + case "text": + if (formValues[editing].length > 60) + return ( + <> + Value: +