├── .gitignore
├── README.md
├── package.json
├── packages
├── frontend
│ ├── .nowignore
│ ├── README.md
│ ├── apollo.config.js
│ ├── codegen.yml
│ ├── now.json
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ └── manifest.json
│ ├── src
│ │ ├── App.tsx
│ │ ├── Router.tsx
│ │ ├── components
│ │ │ ├── AttributesTable
│ │ │ │ └── AttributesTable.tsx
│ │ │ ├── BuyButton
│ │ │ │ └── BuyButton.tsx
│ │ │ ├── CardProduct
│ │ │ │ └── CardProduct.tsx
│ │ │ ├── NavBar
│ │ │ │ └── NavBar.tsx
│ │ │ ├── QuantitySelector
│ │ │ │ └── QuantitySelector.tsx
│ │ │ ├── Select
│ │ │ │ └── Select.tsx
│ │ │ └── VariantSelector
│ │ │ │ └── VariantSelector.tsx
│ │ ├── generated-types.tsx
│ │ ├── helpers
│ │ │ └── variants.ts
│ │ ├── index.css
│ │ ├── index.tsx
│ │ ├── pages
│ │ │ ├── Collection
│ │ │ │ ├── Collection.graphql
│ │ │ │ ├── Collection.tsx
│ │ │ │ └── CollectionContainer.ts
│ │ │ ├── Product
│ │ │ │ ├── Product.graphql
│ │ │ │ ├── Product.tsx
│ │ │ │ └── ProductContainer.ts
│ │ │ └── Welcome
│ │ │ │ ├── Welcome.graphql
│ │ │ │ ├── Welcome.tsx
│ │ │ │ └── WelcomeContainer.ts
│ │ ├── react-app-env.d.ts
│ │ └── serviceWorker.ts
│ └── tsconfig.json
└── server
│ ├── .nowignore
│ ├── .vscode
│ └── settings.json
│ ├── now.json
│ ├── output.json
│ ├── package.json
│ ├── prisma
│ ├── db
│ │ └── next.db
│ ├── migrations
│ │ ├── 20190618213953-init
│ │ │ ├── README.md
│ │ │ ├── datamodel.prisma
│ │ │ └── steps.json
│ │ ├── dev
│ │ │ └── watch-20190618214934
│ │ │ │ ├── README.md
│ │ │ │ ├── datamodel.prisma
│ │ │ │ └── steps.json
│ │ └── lift.lock
│ ├── project.prisma
│ └── seed.ts
│ ├── src
│ ├── context.ts
│ ├── fragments
│ │ ├── ProductVariant.ts
│ │ └── index.ts
│ ├── graphql
│ │ ├── Attributes.ts
│ │ ├── Brand.ts
│ │ ├── Collection.ts
│ │ ├── CollectionRule.ts
│ │ ├── Image.ts
│ │ ├── Option.ts
│ │ ├── OptionValue.ts
│ │ ├── Product.ts
│ │ ├── Query.ts
│ │ ├── Variant.ts
│ │ ├── common.ts
│ │ ├── index.ts
│ │ └── utils.ts
│ ├── index.ts
│ ├── nexus-typegen.ts
│ ├── schema.graphql
│ └── utils
│ │ ├── attributes.ts
│ │ ├── collection.ts
│ │ ├── ids.ts
│ │ └── variants.ts
│ └── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .yalc
3 | yalc.lock
4 | .idea
5 |
6 | # CRA
7 | /src/front/.pnp
8 | /src/front/front.pnp.js
9 | # testing
10 | /src/front/coverage
11 | # production
12 | /src/front/build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | dist
25 | build
26 | .env*
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⚠️ Archive Notice
2 |
3 | Please note that this repo is no longer actively maintained and has been archived.
4 |
5 | We recommend checking out the following full stack frameworks which accomplish similar goals to Fluidstack:
6 |
7 | - [**RedwoodJS**](https://redwoodjs.com/) - JAMStack apps with React, GraphQL, and Prisma
8 | - [**Blitz**](https://github.com/blitz-js/blitz) - Zero-API data layer built on Next.js and inspired by Ruby on Rails
9 |
10 | # FluidStack (2019 edition)
11 |
12 | The constantly evolving modern web stack.
13 |
14 | ## Description
15 |
16 | FluidStack is an production-ready e-commerce app designed as a reference implementation of best practices to help developers build better products. It serves as a central place to find examples of common patterns needed across web development, built by the community for the community.
17 |
18 | ## Motivation
19 |
20 | Building web apps can be challenging considering the amount of technologies that are available. While there are tons of services out there to help you build your product, each of them create examples based on their business activity, making it often hard to connect the dots.
21 |
22 | That's why at Prisma, Stripe, ZEIT and Algolia, we're dedicated to unify our efforts to provide a reference implementation to showcase many best practices of each layers in your app in one single place.
23 |
24 | ## Why e-commerce ?
25 |
26 | Most applications rely on a common set of challenges that can be hard to get right. We think e-commerce is one of many domains that gather the most of these challenges while also being friendly to everyone.
27 |
28 | ## Stack
29 |
30 | - 💨 Data-layer for database access, CI/CD migrations and data visualisation with Prisma
31 | - ⭐ One-click serverless deploy with Now (ZEIT)
32 | - 🤗 SEO-friendly & server-side rendered with NextJS (ZEIT)
33 | - 💸 Ready-to-sell Stripe integration
34 | - 🔍 Blazing-fast search capabilities with Algolia
35 |
36 | ## Features
37 |
38 | - Fully customisable products
39 | - Multi-authentication support
40 | - Internationalisation
41 | - Role permissions support
42 | - Text-search capabilities
43 | - Multi-currencies
44 | - Multi-criteria filtering
45 | - Handcrafted backoffice to manage your shop
46 |
47 | ## Design goals
48 |
49 | ### End-to-end type-safety
50 |
51 | End-to-end type-safe means that your entire application, from the database accesses to your frontend is type-safe.
52 |
53 | It incredibly enhance the developer experience by providing auto-completion everywhere across your app, compile-time errors, higher confidence in code, better refactoring capabilities, and faster iterations.
54 |
55 | - Type-safe database access (Prisma)
56 | - Type-safe API layer (Nexus)
57 | - Type-safe front-end (GraphQL code generation)
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "workspaces": [
4 | "packages/*"
5 | ],
6 | "prettier": {
7 | "semi": false,
8 | "trailingComma": "all",
9 | "singleQuote": true
10 | },
11 | "license": "MIT"
12 | }
13 |
--------------------------------------------------------------------------------
/packages/frontend/ .nowignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .env*
--------------------------------------------------------------------------------
/packages/frontend/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `npm start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `npm test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `npm run build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `npm run eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
35 |
36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
37 |
38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
--------------------------------------------------------------------------------
/packages/frontend/apollo.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | client: {
3 | name: 'ecommerce-project',
4 | service: {
5 | url: 'http://localhost:4000',
6 | },
7 | includes: ['src/**/*.{ts,tsx,graphql}'],
8 | },
9 | }
10 |
--------------------------------------------------------------------------------
/packages/frontend/codegen.yml:
--------------------------------------------------------------------------------
1 | overwrite: true
2 | watch: true
3 | schema: http://localhost:4000
4 | documents: ./src/**/*.graphql
5 | generates:
6 | src/generated-types.tsx:
7 | plugins:
8 | - typescript
9 | - typescript-operations
10 | - typescript-react-apollo
11 | config:
12 | avoidOptional: true
13 | typesPrefix: I
14 |
--------------------------------------------------------------------------------
/packages/frontend/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "name": "webshop",
4 | "builds": [
5 | {
6 | "src": "package.json",
7 | "use": "@now/static-build",
8 | "config": { "distDir": "build" }
9 | }
10 | ],
11 | "build": {
12 | "env": {
13 | "REACT_APP_PRISMA_ENDPOINT": "@webshop-graphql-staging"
14 | }
15 | },
16 | "routes": [
17 | {
18 | "src": "/static/(.*)",
19 | "headers": { "cache-control": "s-maxage=31536000,immutable" },
20 | "dest": "/static/$1"
21 | },
22 | { "src": "/favicon.ico", "dest": "/favicon.ico" },
23 | { "src": "/asset-manifest.json", "dest": "/asset-manifest.json" },
24 | { "src": "/manifest.json", "dest": "/manifest.json" },
25 | { "src": "/precache-manifest.(.*)", "dest": "/precache-manifest.$1" },
26 | {
27 | "src": "/service-worker.js",
28 | "headers": { "cache-control": "s-maxage=0" },
29 | "dest": "/service-worker.js"
30 | },
31 | {
32 | "src": "/(.*)",
33 | "headers": { "cache-control": "s-maxage=0" },
34 | "dest": "/index.html"
35 | }
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/packages/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webshop-front",
3 | "version": "0.0.0",
4 | "private": true,
5 | "dependencies": {
6 | "apollo-boost": "^0.1.23",
7 | "graphql": "^14.0.2",
8 | "graphql-tag": "^2.10.0",
9 | "lodash": "^4.17.11",
10 | "react": "^16.8.4",
11 | "react-apollo": "^2.3.3",
12 | "react-dom": "^16.8.4",
13 | "react-router-dom": "^4.3.1",
14 | "react-scripts": "2.1.1",
15 | "styled-components": "^4.1.3",
16 | "styled-icons": "^7.7.0",
17 | "typescript": "^3.2.2"
18 | },
19 | "devDependencies": {
20 | "@graphql-codegen/cli": "^1.2.1",
21 | "@graphql-codegen/typescript": "^1.2.1",
22 | "@graphql-codegen/typescript-operations": "^1.2.1",
23 | "@graphql-codegen/typescript-react-apollo": "^1.2.1",
24 | "@types/jest": "^24.0.15",
25 | "@types/node": "^12.0.8",
26 | "@types/react": "^16.7.17",
27 | "@types/react-dom": "^16.0.11",
28 | "@types/react-router-dom": "^4.3.1",
29 | "@types/styled-components": "^4.1.12",
30 | "concurrently": "^4.1.0"
31 | },
32 | "scripts": {
33 | "start": "react-scripts start",
34 | "build": "react-scripts build",
35 | "test": "react-scripts test",
36 | "eject": "react-scripts eject",
37 | "generate": "gql-gen",
38 | "deploy": "now",
39 | "now-build": "react-scripts build"
40 | },
41 | "eslintConfig": {
42 | "extends": "react-app"
43 | },
44 | "browserslist": [
45 | ">0.2%",
46 | "not dead",
47 | "not ie <= 11",
48 | "not op_mini all"
49 | ],
50 | "prettier": {
51 | "semi": false,
52 | "singleQuote": true,
53 | "trailingComma": "es5"
54 | },
55 | "moduleFileExtensions": [
56 | "ts",
57 | "tsx",
58 | "js",
59 | "jsx",
60 | "json",
61 | "node"
62 | ]
63 | }
64 |
--------------------------------------------------------------------------------
/packages/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fluidstackdev/fluidstack/c9b47df985d70fb5134d252723dc087638b1de69/packages/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/packages/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/packages/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/packages/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import ApolloClient from 'apollo-boost'
3 | import { ApolloProvider } from 'react-apollo'
4 |
5 | import Router from './Router'
6 |
7 | const client = new ApolloClient({
8 | uri: process.env.REACT_APP_PRISMA_ENDPOINT,
9 | })
10 |
11 | class App extends Component {
12 | render() {
13 | return (
14 |
15 |
16 |
17 | )
18 | }
19 | }
20 |
21 | export default App
22 |
--------------------------------------------------------------------------------
/packages/frontend/src/Router.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
3 | import Welcome from './pages/Welcome/WelcomeContainer'
4 | import CollectionContainer from './pages/Collection/CollectionContainer'
5 | import ProductContainer from './pages/Product/ProductContainer'
6 |
7 | const AppRouter = () => (
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 | )
19 |
20 | export default AppRouter
21 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/AttributesTable/AttributesTable.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const AttributesTable = styled.table`
4 | border-collapse: collapse;
5 | border-spacing: 0;
6 | width: 100%;
7 | border: 1px solid #ddd;
8 | text-align: left;
9 |
10 | tr:nth-child(odd) {
11 | background-color: #f2f2f2;
12 | }
13 | `
14 |
15 | export const AttributeName = styled.th`
16 | padding: 0.75rem;
17 | vertical-align: top;
18 | border-top: 1px solid #dee2e6;
19 | letter-spacing: 0.8px;
20 | `
21 |
22 | export const AttributeValue = styled.td`
23 | padding: 0.75rem;
24 | vertical-align: top;
25 | border-top: 1px solid #dee2e6;
26 | letter-spacing: 0.8px;
27 | `
28 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/BuyButton/BuyButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | export const BuyButton = styled.a`
5 | cursor: pointer;
6 | text-decoration: none;
7 | color: #fff;
8 | letter-spacing: 0.05em;
9 | border: 2px solid #ff4c3b;
10 | background-image: linear-gradient(30deg, #ff4c3b 50%, transparent 0);
11 | background-size: 540px;
12 | background-repeat: no-repeat;
13 | background-position: 0;
14 | transition: background 0.3s ease-in-out;
15 | line-height: 20px;
16 | text-transform: uppercase;
17 | font-size: 14px;
18 | font-weight: 700;
19 | border-radius: 0;
20 | text-align: center;
21 | white-space: nowrap;
22 | vertical-align: middle;
23 | padding: 7px 25px;
24 | margin-right: 8px;
25 |
26 | &:hover {
27 | background-position: 100%;
28 | color: #000;
29 | background-color: #fff;
30 | }
31 | `
32 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/CardProduct/CardProduct.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import styled from 'styled-components'
3 | import { ICollectionProductFragment, IOptionValue } from '../../generated-types'
4 | import {
5 | getAvailableOptionValues,
6 | getDisplayPrice,
7 | getIterableVariants,
8 | isOptionValueAvailable,
9 | sameOptionValue,
10 | } from '../../helpers/variants'
11 | import { Link } from 'react-router-dom'
12 |
13 | interface Props {
14 | product: ICollectionProductFragment
15 | }
16 |
17 | export const CardProduct: React.SFC = props => {
18 | const [optionValue, setOptionValue] = useState(null)
19 | const iterableVariants = getIterableVariants(props.product.variants!)
20 | const availableOptionValues = optionValue
21 | ? getAvailableOptionValues([optionValue], props.product.variants!)
22 | : null
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | {props.product.brand.name}
33 | {props.product.name}
34 |
35 | {getDisplayPrice(props.product) / 100} €
36 |
37 |
38 | {iterableVariants.map(([optionName, values]) => (
39 |
40 | {optionName}
41 |
42 | {values.map(v => {
43 | if (v.isColor) {
44 | return (
45 |
49 | setOptionValue(
50 | sameOptionValue(v, optionValue) ? null : v
51 | )
52 | }
53 | available={isOptionValueAvailable(
54 | v,
55 | [],
56 | availableOptionValues
57 | )}
58 | />
59 | )
60 | } else {
61 | return (
62 |
70 | setOptionValue(
71 | sameOptionValue(v, optionValue) ? null : v
72 | )
73 | }
74 | >
75 | {v.name}
76 |
77 | )
78 | }
79 | })}
80 |
81 |
82 | ))}
83 |
84 |
85 |
86 | )
87 | }
88 |
89 | const Container = styled.div`
90 | padding-right: 16px;
91 | padding-bottom: 16px;
92 | flex-grow: 1;
93 | flex-basis: 20%;
94 | display: flex; /* so child elements can use flexbox stuff too! */
95 | flex-direction: column;
96 | transition: all 0.2s ease-in-out;
97 |
98 | &:hover {
99 | transform: scale(1.1);
100 | }
101 | `
102 |
103 | const ImageContainer = styled(Link)`
104 | box-sizing: border-box;
105 | text-decoration: none;
106 | color: black;
107 | `
108 |
109 | const Image = styled.img`
110 | display: block;
111 | max-width: 100%;
112 | object-fit: cover;
113 | margin-bottom: 4px;
114 | `
115 |
116 | const DetailContainer = styled.div`
117 | display: flex;
118 | flex-direction: column;
119 | background-color: white;
120 | padding: 8px 12px;
121 | `
122 |
123 | const TopDetailContainer = styled(Link)`
124 | display: flex;
125 | flex-direction: row;
126 | justify-content: space-between;
127 | margin-bottom: 8px;
128 | box-sizing: border-box;
129 | text-decoration: none;
130 | color: black;
131 | `
132 |
133 | const LabelsContainer = styled.div`
134 | display: flex;
135 | flex-direction: column;
136 | `
137 | const BrandName = styled.span`
138 | color: #1a1a1a;
139 | font-weight: 700;
140 | margin-bottom: 2px;
141 | font-size: 12px;
142 | `
143 | const ProductName = styled.span`
144 | color: #1a1a1a;
145 | margin-bottom: 2px;
146 | font-size: 12px;
147 | `
148 |
149 | const Price = styled.span`
150 | color: #1a1a1a;
151 | margin-bottom: 2px;
152 | font-size: 12px;
153 | `
154 |
155 | const OptionValuesContainer = styled.div``
156 |
157 | const OptionValuesGroup = styled.div`
158 | display: flex;
159 | flex-direction: row;
160 | align-items: center;
161 | justify-content: space-between;
162 | margin-bottom: 4px;
163 | `
164 |
165 | const OptionValuesGroupTitle = styled.span`
166 | font-size: 12px;
167 | font-weight: 600;
168 | margin-right: 5px;
169 | `
170 |
171 | const OptionValue = styled.span<{ available: boolean }>`
172 | font-size: 12px;
173 | padding: 3px;
174 | border-radius: 2px;
175 | margin-right: 2px;
176 | min-width: 20px;
177 | text-align: center;
178 |
179 | ${props => `background-color: ${props.available ? '#0000000d' : 'white'};`}
180 | ${props => `color: ${props.available ? 'black' : '#00000070'};`}
181 |
182 | &:hover {
183 | cursor: pointer;
184 | background-color: #00000014;
185 | }
186 | `
187 |
188 | const colors: Record = {
189 | red: '#ff7979',
190 | blue: '#686de0',
191 | green: '#badc58',
192 | violet: '#e056fd',
193 | }
194 |
195 | const ColorValue = styled.div<{ color: string; available: boolean }>`
196 | width: 15px;
197 | height: 15px;
198 | margin-right: 4px;
199 | ${props =>
200 | `background-color: ${
201 | props.available ? colors[props.color.toLowerCase()] : '#e0e0e0'
202 | }`}
203 |
204 | &:hover {
205 | cursor: pointer;
206 | }
207 | `
208 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/NavBar/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import React, { Component, FunctionComponent } from 'react'
2 | import { Link } from 'react-router-dom'
3 |
4 | export interface NavItem {
5 | label: string
6 | link: string
7 | }
8 |
9 | interface Props {
10 | items: NavItem[]
11 | }
12 |
13 | export const NavBar: React.SFC = props => (
14 |
23 | )
24 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/QuantitySelector/QuantitySelector.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import styled from 'styled-components'
3 | import * as Fa from 'styled-icons/fa-solid'
4 |
5 | interface Props {
6 | title: string
7 | onQuantityChange: (quantity: number) => void
8 | }
9 |
10 | function tryParseInt(str: string, defaultValue: number) {
11 | if (/^\d+$/g.test(str) === true) return parseInt(str)
12 |
13 | return defaultValue
14 | }
15 |
16 | export const QuantitySelector: React.SFC = props => {
17 | const [quantity, setQuantity] = useState(1)
18 |
19 | const setQuantityValue = (quantity: number) => {
20 | const newQuantity = Math.max(1, quantity)
21 | setQuantity(newQuantity)
22 | props.onQuantityChange(newQuantity)
23 | }
24 |
25 | return (
26 |
27 | {props.title}
28 |
29 | setQuantityValue(quantity - 1)}>
30 |
31 |
32 |
37 | setQuantityValue(
38 | tryParseInt(event.target.value.replace(/\D/, ''), 1),
39 | )
40 | }
41 | />
42 | setQuantityValue(quantity + 1)}>
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | const Container = styled.div`
51 | display: flex;
52 | flex-direction: column;
53 | `
54 |
55 | const QuantityContainer = styled.div`
56 | display: flex;
57 | flex-direction: row;
58 | `
59 |
60 | const Title = styled.span`
61 | font-size: 16px;
62 | font-weight: 500;
63 | margin-top: 16px;
64 | margin-bottom: 8px;
65 | `
66 |
67 | const ValueSelector = styled.button`
68 | background: #fff !important;
69 | border: 1px solid #ced4da;
70 | padding-left: 12px;
71 | font-size: 12px;
72 | font-weight: 900;
73 | line-height: 1;
74 | text-align: center;
75 | white-space: nowrap;
76 | vertical-align: middle;
77 | user-select: none;
78 | outline: 0;
79 | `
80 |
81 | const NumericInput = styled.input`
82 | padding: 0.375rem 0.75rem;
83 | font-size: 1rem;
84 | line-height: 1.5;
85 | color: #495057;
86 | background-color: #fff;
87 | background-clip: padding-box;
88 | border: 1px solid #ced4da;
89 | text-align: center;
90 | width: 80px;
91 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
92 | z-index: 2;
93 | border-right: none;
94 | border-left: none;
95 |
96 | &:focus {
97 | color: #495057;
98 | background-color: #fff;
99 | border-color: #80bdff;
100 | outline: 0;
101 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
102 | }
103 | `
104 |
--------------------------------------------------------------------------------
/packages/frontend/src/components/Select/Select.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
2 | import styled from 'styled-components'
3 |
4 | type Option = { label: string; value: string }
5 |
6 | type Props = {
7 | title: string
8 | name: string
9 | setFilter: (name: string, values: any) => void
10 | state: any
11 | options: Option[]
12 | }
13 |
14 | const optionValue = (o: Option) => o.value
15 | const optionLabel = (o: Option) => o.label
16 |
17 | const addOrRemoveOption = (selected: Option[], option: Option) => {
18 | if (isSelected(option, selected)) {
19 | return selected.filter(s => optionValue(s) !== optionValue(option))
20 | }
21 |
22 | return [...selected, option]
23 | }
24 |
25 | const isSelected = (option: Option, selected: Option[]) => {
26 | return (
27 | selected.find(s => optionValue(s) === optionValue(option)) !== undefined
28 | )
29 | }
30 |
31 | function useComponentVisible(
32 | initialIsOpened: boolean,
33 | onVisibilityChange: (visibility: boolean) => void,
34 | ) {
35 | const [opened, setOpened] = useState(initialIsOpened)
36 | const ref = useRef(null) as any
37 |
38 | const handleHideDropdown = (event: KeyboardEvent) => {
39 | if (event.key === 'Escape') {
40 | onVisibilityChange(false)
41 | }
42 | }
43 |
44 | const handleClickOutside = (event: any) => {
45 | if (ref.current && !ref.current.contains(event.target)) {
46 | onVisibilityChange(false)
47 | }
48 | }
49 |
50 | useEffect(() => {
51 | document.addEventListener('keydown', handleHideDropdown, true)
52 | document.addEventListener('click', handleClickOutside, true)
53 | return () => {
54 | document.removeEventListener('keydown', handleHideDropdown, true)
55 | document.removeEventListener('click', handleClickOutside, true)
56 | }
57 | })
58 |
59 | return { ref, opened, setOpened }
60 | }
61 |
62 | function useOuterClickNotifier(innerRef: any, onOuterClick: () => void) {
63 | useLayoutEffect(() => {
64 | function handleClick(e: any) {
65 | if (innerRef.current && !innerRef.current.contains(e.target)) {
66 | onOuterClick()
67 | }
68 | }
69 | document.addEventListener('click', handleClick)
70 | // called for each previous layout effect
71 | return () => document.removeEventListener('click', handleClick)
72 | }, [onOuterClick, innerRef]) // invoke again, if inputs have changed
73 | }
74 |
75 | export const FilterSelect: React.SFC = props => {
76 | const [selected, setSelected] = useState