├── .eslintignore ├── .eslintrc ├── .gitignore ├── .prettierrc.json ├── icons ├── Facebook.tsx ├── Loading.tsx └── Twitter.tsx ├── lib ├── constants.ts ├── shareUrls.ts ├── types.ts └── useQueryParams.ts ├── next-env.d.ts ├── next.config.js ├── now.json ├── package.json ├── pages ├── _app.tsx ├── api │ └── search.ts └── index.tsx ├── postcss.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── banner.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── fivestar_logo_black_2x.png ├── no_image.jpg └── site.webmanifest ├── styles └── _main.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | next.config.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "ecmaVersion": 2018, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "env": { 11 | "es6": true, 12 | "browser": true, 13 | "node": true 14 | }, 15 | "extends": [ 16 | "plugin:@typescript-eslint/recommended", 17 | "prettier", 18 | "prettier/@typescript-eslint", 19 | "prettier/react", 20 | "plugin:prettier/recommended", 21 | "plugin:react/recommended" 22 | ], 23 | "plugins": [ 24 | "@typescript-eslint", 25 | "react-hooks" 26 | ], 27 | "rules": { 28 | "react-hooks/rules-of-hooks": "error", 29 | "react-hooks/exhaustive-deps": "warn", 30 | "prettier/prettier": "error", 31 | "@typescript-eslint/no-explicit-any": 0, 32 | "@typescript-eslint/explicit-function-return-type": 0, 33 | "@typescript-eslint/explicit-member-accessibility": 0, 34 | "react/no-unescaped-entities": 0 35 | }, 36 | "settings": { 37 | "react": { 38 | "version": "detect" 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /icons/Facebook.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes } from 'react'; 2 | 3 | export default function Facebook(props: HTMLAttributes) { 4 | return ( 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /icons/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes } from 'react'; 2 | 3 | // #4a5568 is from gray-700 4 | export default function Loading(props: HTMLAttributes) { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 25 | 26 | 27 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /icons/Twitter.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes } from 'react'; 2 | 3 | export default function Twitter(props: HTMLAttributes) { 4 | return ( 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const META_NAME = 'Fivestar'; 2 | export const META_DESCRIPTION = 3 | 'The secret ⭐⭐⭐⭐⭐ Amazon search algorithm. Confidently find the best products on Amazon without the hassle.'; 4 | export const TWITTER_AUTHOR = '@mattgcondon'; 5 | export const HOST_URL = 'https://fivestar.io'; 6 | export const SHARE_TEXT = `I've stopped agonizing over Amazon reviews and learned to love the ${HOST_URL} secret search algorithm ❤️`; 7 | 8 | export const POPULAR_TERMS = [ 9 | 'toaster', 10 | 'bathroom trashcan', 11 | 'long iphone cable', 12 | 'pine candle', 13 | 'laptop stand', 14 | 'car stereo', 15 | 'bluetooth in-ear headphones', 16 | 'usb microphone', 17 | 'face moisturizer', 18 | 'lawn mower', 19 | 'mason jars', 20 | 'beard trimmer', 21 | 'webcam cover', 22 | '6ft hdmi cable', 23 | 'patio table', 24 | 'cookware set', 25 | 'exercise ball', 26 | 'dog toy', 27 | 'dash cam', 28 | 'portable folding chair', 29 | ]; 30 | -------------------------------------------------------------------------------- /lib/shareUrls.ts: -------------------------------------------------------------------------------- 1 | import { HOST_URL } from './constants'; 2 | 3 | /* eslint-disable @typescript-eslint/camelcase */ 4 | export const buildTwitterShareUrl = (text: string) => 5 | `https://twitter.com/intent/tweet?${new URLSearchParams({ text })}`; 6 | 7 | export const buildFacebookShareUrl = (text: string) => 8 | `https://www.facebook.com/dialog/feed?${new URLSearchParams({ 9 | app_id: '740195929372066', 10 | display: 'page', 11 | link: HOST_URL, 12 | redirect_uri: process.browser ? window.location.href : HOST_URL, 13 | })}`; 14 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResponse { 2 | searchUrl: string; // link to this search on amazon 3 | query: string; // the original query 4 | buckets: Bucket[]; 5 | } 6 | 7 | export interface Bucket { 8 | minPrice: number; // the minimum price in this bucket, used as a key 9 | items: Item[]; 10 | } 11 | 12 | export interface Item { 13 | title: string; // title 14 | maker: string; // subtitle 15 | imageUrl: string; // image 16 | detailPageUrl: string; // detail page 17 | priceDisplayAmount: string; // price 18 | } 19 | -------------------------------------------------------------------------------- /lib/useQueryParams.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import Router from 'next/router'; 3 | import { useState, useCallback, useEffect } from 'react'; 4 | 5 | export default function useQueryParams(): [ 6 | Record, 7 | (value: Record) => void, 8 | ] { 9 | const router = useRouter(); 10 | 11 | const [query, _setQuery] = useState(() => router.query as Record); 12 | 13 | const setQuery = useCallback( 14 | (values: Record) => { 15 | const url = `${window.location.pathname}?${new URLSearchParams(values)}`; 16 | router.replace(url, url, { shallow: true }); 17 | _setQuery(values); 18 | }, 19 | [router], 20 | ); 21 | 22 | useEffect(() => { 23 | const routeChangeComplete = (url: string) => { 24 | _setQuery(Router.query as Record); 25 | }; 26 | 27 | Router.events.on('routeChangeComplete', routeChangeComplete); 28 | return () => { 29 | Router.events.off('routeChangeComplete', routeChangeComplete); 30 | }; 31 | }, []); 32 | 33 | return [query, setQuery]; 34 | } 35 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | env: { 4 | GA_TRACKING_ID: process.env.GA_TRACKING_ID 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "fivestar", 4 | "build": { 5 | "env": { 6 | "GA_TRACKING_ID": "@fivestar-ga-tracking-id" 7 | } 8 | }, 9 | "env": { 10 | "PAAPI_ACCESS_KEY": "@fivestar-paapi-access-key", 11 | "PAAPI_SECRET_KEY": "@fivestar-paapi-secret-key", 12 | "PAAPI_PARTNER_TAG": "@fivestar-paapi-partner-tag", 13 | "GA_TRACKING_ID": "@fivestar-ga-tracking-id" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fivestar", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "10.0.1", 12 | "paapi5-nodejs-sdk": "^1.0.1", 13 | "react": "17.0.1", 14 | "react-async": "^10.0.0", 15 | "react-dom": "17.0.1", 16 | "react-flip-move": "^3.0.4", 17 | "react-ga": "^2.7.0", 18 | "use-debounce": "^3.3.0" 19 | }, 20 | "devDependencies": { 21 | "@fullhuman/postcss-purgecss": "^2.0.6", 22 | "@types/lodash": "^4.14.149", 23 | "@types/node": "^13.7.0", 24 | "@types/react": "^16.9.19", 25 | "@typescript-eslint/eslint-plugin": "^2.19.0", 26 | "@typescript-eslint/parser": "^2.19.0", 27 | "eslint": "^6.8.0", 28 | "eslint-config-prettier": "^6.10.0", 29 | "eslint-plugin-prettier": "^3.1.2", 30 | "eslint-plugin-react": "^7.18.3", 31 | "eslint-plugin-react-hooks": "^2.3.0", 32 | "postcss-import": "^12.0.1", 33 | "postcss-preset-env": "^6.7.0", 34 | "prettier": "^1.19.1", 35 | "tailwindcss": "^1.1.4", 36 | "typescript": "^3.7.5" 37 | }, 38 | "browserslist": [ 39 | "> 1%", 40 | "last 2 versions", 41 | "not ie <= 8" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { AppProps } from 'next/app'; 3 | import ReactGA from 'react-ga'; 4 | import Head from 'next/head'; 5 | import Router from 'next/router'; 6 | 7 | import '../styles/_main.css'; 8 | import { HOST_URL, META_DESCRIPTION, META_NAME, TWITTER_AUTHOR } from '../lib/constants'; 9 | 10 | ReactGA.initialize(process.env.GA_TRACKING_ID); 11 | 12 | const absoluteUri = (path: string) => `${HOST_URL}${path}`; 13 | 14 | function Fivestar({ Component, pageProps }: AppProps) { 15 | useEffect(() => { 16 | // register initial pageview on browser mount 17 | ReactGA.pageview(window.location.pathname); 18 | // register future route changes 19 | Router.events.on('routeChangeComplete', url => ReactGA.pageview(url)); 20 | }, []); 21 | return ( 22 | <> 23 | 24 | 25 | {META_NAME} | {META_DESCRIPTION} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | 52 |
53 | 54 | 67 | 68 | ); 69 | } 70 | 71 | export default Fivestar; 72 | -------------------------------------------------------------------------------- /pages/api/search.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import PAAPI from 'paapi5-nodejs-sdk'; 3 | import { promisify } from 'util'; 4 | import get from 'lodash/get'; 5 | import map from 'lodash/map'; 6 | import findLast from 'lodash/findLast'; 7 | import { Bucket, SearchResponse, Item } from '../../lib/types'; 8 | 9 | // -> price in usd (*100 because that makes cents) 10 | const BUCKET_PRICES = [ 11 | 0, 12 | 500, 13 | 1000, 14 | 1500, 15 | 2000, 16 | 2500, 17 | 3000, 18 | 3500, 19 | 4000, 20 | 5000, 21 | 10000, 22 | 15000, 23 | 20000, 24 | 25000, 25 | 30000, 26 | 35000, 27 | 40000, 28 | 45000, 29 | 50000, 30 | 55000, 31 | 60000, 32 | ]; 33 | 34 | // configure PAAPI client 35 | const client = PAAPI.ApiClient.instance; 36 | client.accessKey = process.env.PAAPI_ACCESS_KEY; 37 | client.secretKey = process.env.PAAPI_SECRET_KEY; 38 | client.host = 'webservices.amazon.com'; 39 | client.region = 'us-east-1'; 40 | 41 | // create a default api client 42 | const paapi = new PAAPI.DefaultApi(); 43 | // promisify the searchItems function because it's 2020 amazon 44 | const searchItems = promisify(paapi.searchItems.bind(paapi)); 45 | 46 | const buildSearchItemsRequest = (keywords: string) => { 47 | const request = new PAAPI.SearchItemsRequest(); 48 | 49 | request['PartnerTag'] = process.env.PAAPI_PARTNER_TAG; 50 | request['PartnerType'] = 'Associates'; // only valid value 51 | request['Keywords'] = keywords; 52 | request['SearchIndex'] = 'All'; // default 53 | request['Availability'] = 'Available'; // default 54 | request['SortBy'] = 'Relevance'; // we sort by sales rank later 55 | request['Merchant'] = 'All'; // default 56 | request['Marketplace'] = 'www.amazon.com'; // default 57 | request['CurrencyOfPreference'] = 'USD'; // default for www.amazon.com 58 | // request['DeliveryFalgs'] = ''; // we may want to restrict delivery types in the future 59 | request['Resources'] = [ 60 | 'ItemInfo.Title', // title 61 | 'Images.Primary.Large', // image 62 | 'ItemInfo.ByLineInfo', // brand/manufacturer 63 | 'BrowseNodeInfo.WebsiteSalesRank', // sales rank 64 | 'Offers.Listings.Price', // listing price 65 | 'Offers.Listings.IsBuyBoxWinner', // determine the winning listing 66 | 'Offers.Summaries.LowestPrice', // lowest price in offers 67 | ]; 68 | 69 | return request; 70 | }; 71 | 72 | const getWinningListing = (item: any): any => 73 | (get(item, ['Offers', 'Listings'], []) as any[]) // 74 | .find(listing => get(listing, ['IsBuyBoxWinner'], false)); 75 | 76 | const getPrice = (item: any): number => { 77 | const winningListing = getWinningListing(item); 78 | const listingPrice = get(winningListing, ['Price', 'Amount']); 79 | 80 | if (!listingPrice) { 81 | throw new Error(`No winning Listing in ${JSON.stringify(item)}`); 82 | } 83 | 84 | return listingPrice * 100; // convert to cents 85 | }; 86 | 87 | const formatToResponseItem = (item: any): Item => { 88 | return { 89 | title: get(item, ['ItemInfo', 'Title', 'DisplayValue']), 90 | maker: get(item, ['ItemInfo', 'ByLineInfo', 'Brand', 'DisplayValue'], 'Unknown'), 91 | detailPageUrl: get(item, ['DetailPageURL']), 92 | imageUrl: get(item, ['Images', 'Primary', 'Large', 'URL'], '/no_image.jpg'), 93 | priceDisplayAmount: get(getWinningListing(item), ['Price', 'DisplayAmount']), 94 | }; 95 | }; 96 | 97 | export default async (req: NextApiRequest, res: NextApiResponse) => { 98 | const query = req.query.q as string; 99 | if (!query) { 100 | throw new Error('Expected `q` parameter, got nothing.'); 101 | } 102 | 103 | const request = buildSearchItemsRequest(query); 104 | let data: any; 105 | try { 106 | data = await searchItems(request); 107 | } catch (error) { 108 | return res.status(500).json({ message: `Error from Amazon: ${error.message}` }); 109 | } 110 | const searchUrl: string = get(data, ['SearchResult', 'SearchURL']); 111 | const Items: any[] = get(data, ['SearchResult', 'Items'], []); 112 | 113 | const bucketsByPrice = Items.reduce>((memo, item) => { 114 | try { 115 | const itemPrice = getPrice(item); 116 | // find the correct bucket given the price 117 | const bucketPrice = findLast(BUCKET_PRICES, bp => itemPrice > bp).toString(); 118 | 119 | const responseItem = formatToResponseItem(item); 120 | if (memo[bucketPrice]) { 121 | memo[bucketPrice].push(responseItem); 122 | } else { 123 | memo[bucketPrice] = [responseItem]; 124 | } 125 | } catch {} // ignore an item if errors thrown in getPrice, etc 126 | 127 | return memo; 128 | }, {}); 129 | 130 | const buckets: Bucket[] = map(bucketsByPrice, (value, key) => ({ 131 | minPrice: parseInt(key, 10), // TODO: this is annoying, but we need to key by number... 132 | items: value, 133 | })); 134 | 135 | const response: SearchResponse = { 136 | searchUrl, 137 | buckets, 138 | query, 139 | }; 140 | 141 | return res.status(200).json(response); 142 | }; 143 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useEffect } from 'react'; 2 | import Head from 'next/head'; 3 | import Facebook from '../icons/Facebook'; 4 | import Twitter from '../icons/Twitter'; 5 | import { useAsync } from 'react-async'; 6 | import FlipMove from 'react-flip-move'; 7 | import noop from 'lodash/noop'; 8 | import { META_NAME, POPULAR_TERMS, SHARE_TEXT } from '../lib/constants'; 9 | import Loading from '../icons/Loading'; 10 | import { SearchResponse } from '../lib/types'; 11 | import ReactGA from 'react-ga'; 12 | import { buildFacebookShareUrl, buildTwitterShareUrl } from '../lib/shareUrls'; 13 | import useQueryParams from '../lib/useQueryParams'; 14 | import { useDebouncedCallback } from 'use-debounce'; 15 | 16 | const formatUSD = (price: number) => `$${(price / 100).toFixed(2)}`; 17 | const queryIsValid = (query: string) => query.length > 0; 18 | const twitterShareUrl = buildTwitterShareUrl(SHARE_TEXT); 19 | const facebookShareUrl = buildFacebookShareUrl(SHARE_TEXT); 20 | 21 | const fetchSearch = async ({ query }) => { 22 | if (!queryIsValid(query)) return undefined; 23 | 24 | const res = await fetch(`/api/search?${new URLSearchParams({ q: query })}`); 25 | let data; 26 | try { 27 | data = await res.json(); 28 | } catch (error) { 29 | throw new Error(res.statusText); 30 | } 31 | 32 | if (!res.ok) { 33 | throw new Error(data.message); 34 | } 35 | return data; 36 | }; 37 | 38 | // TODO: social link generation 39 | 40 | function Home() { 41 | const [{ q }, setParams] = useQueryParams(); 42 | const query = q || ''; // coerce undefined to empty string 43 | 44 | const setQuery = useCallback((query: string) => setParams({ q: query }), [setParams]); 45 | const isValidQuery = queryIsValid(query); 46 | const queryIsEmpty = !isValidQuery; // todo: 47 | const canSearch = isValidQuery; 48 | 49 | const { data, error, isPending, isFulfilled, reload, setData } = useAsync({ 50 | promiseFn: fetchSearch, 51 | query, 52 | debugLabel: '/api/search', 53 | }); 54 | 55 | const showResults = !isPending && isFulfilled && data && isValidQuery; 56 | const showPopularTerms = queryIsEmpty || (isValidQuery && !showResults); 57 | const showPending = isPending; 58 | const showError = error && !isPending; 59 | 60 | const [handleSearch] = useDebouncedCallback( 61 | useCallback(() => { 62 | ReactGA.event({ 63 | category: 'Search', 64 | action: 'search', 65 | label: query, 66 | }); 67 | 1; 68 | 69 | reload(); 70 | }, [query, reload]), 71 | 250, 72 | ); 73 | 74 | const onSearchChange = useCallback(e => setQuery(e.target.value), [setQuery]); 75 | const onSearchKeydown = useCallback(e => e.key === 'Enter' && handleSearch(), [handleSearch]); 76 | const handleClearSearch = useCallback(() => setQuery(''), [setQuery]); 77 | 78 | const searchFor = useCallback( 79 | (query: string) => () => { 80 | setQuery(query); 81 | // wait for setState to flush... 82 | setTimeout(handleSearch, 0); 83 | }, 84 | [handleSearch, setQuery], 85 | ); 86 | 87 | const currentYear = useMemo(() => new Date().getFullYear(), []); 88 | 89 | useEffect(() => { 90 | // if we can search on initial mount, use existing query params 91 | if (canSearch) { 92 | handleSearch(); 93 | } 94 | // eslint-disable-next-line react-hooks/exhaustive-deps 95 | }, []); 96 | 97 | useEffect(() => { 98 | // if query is empty, nuke search state 99 | if (queryIsEmpty) { 100 | setData(undefined); 101 | } 102 | }, [queryIsEmpty, setData]); 103 | 104 | return ( 105 | <> 106 | 107 | {canSearch && ( 108 | 109 | 🔎 for {query} on {META_NAME} 110 | 111 | )} 112 | 113 | 114 |
115 |
116 | 121 | {/* */} 129 |
130 | 131 |
132 |

133 | The Secret Amazon Search 134 |

135 |

136 | Find the best products on Amazon with confidence. 137 |

138 | 139 |

140 | Fivestar has been shut down by Amazon, thanks for using it the past 5 years! 141 |

142 | 143 | {/*
144 | 153 | 160 |
*/} 161 | 162 | {/* 163 | {showPopularTerms && 164 | POPULAR_TERMS.map(term => ( 165 |
170 | {term} 171 |
172 | ))} 173 |
*/} 174 | 175 | 176 | {showError && ( 177 |

178 | {error.message} — Too many people are using {META_NAME} at the moment. Try again in 179 | a few minutes. 180 |

181 | )} 182 | 183 | {showPending && ( 184 |
185 | 186 |
187 | )} 188 | 189 | {showResults && 190 | data.buckets.map((bucket, i) => { 191 | const minPrice = bucket.minPrice; 192 | const maxPrice = 193 | data.buckets.length - 1 > i // 194 | ? data.buckets[i + 1].minPrice 195 | : undefined; 196 | 197 | return ( 198 |
199 |

200 | {maxPrice ? ( 201 | <> 202 | 👇 {formatUSD(maxPrice)} and under 203 | 204 | ) : ( 205 | <> 206 | 💸 over {formatUSD(minPrice)} 207 | 208 | )} 209 |

210 | 233 |
234 | ); 235 | })} 236 |
237 | 238 | {showResults && ( 239 | 240 | Can't find what you're looking for?{' '} 241 | 247 | See more results for '{data.query}' on Amazon.com 248 | 249 | 250 | )} 251 |
252 |
253 | 254 |
255 |
256 |

257 | Fivestar is an Amazon Associate, and all links to Amazon are referrals. 258 |

259 |

260 | Fivestar's search algorithm finds the best products by first searching for relevancy and{' '} 261 | then aggregating by average customer reviews. It's smarter and faster than 262 | searching directly on Amazon. Don't spend minutes reading reviews and deciding between 263 | products—find what you're looking for with confidence. 264 |

265 |

Fivestar searches the USA www.amazon.com marketplace in USD.

266 |

© {currentYear}, Fivestar — est. 2015

267 |
268 |
269 | 270 | 275 | 276 | ); 277 | } 278 | 279 | export default Home; 280 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | tailwindcss: {}, 5 | ...(process.env.NODE_ENV === 'production' 6 | ? { 7 | '@fullhuman/postcss-purgecss': { 8 | content: ['./pages/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'], 9 | defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || [], 10 | whitelist: ['w-10', 'w-24', 'h-10', 'h-24'], 11 | }, 12 | } 13 | : {}), 14 | 'postcss-preset-env': { stage: 2 }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/banner.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/favicon.ico -------------------------------------------------------------------------------- /public/fivestar_logo_black_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/fivestar_logo_black_2x.png -------------------------------------------------------------------------------- /public/no_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrugs/fivestar/4abb1c27070e5721bf7794b40abe5aba2abb1a83/public/no_image.jpg -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fivestar", 3 | "short_name": "Fivestar", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /styles/_main.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:400,600,700&display=swap'); 2 | 3 | /* purgecss start ignore */ 4 | @import 'tailwindcss/base'; 5 | /* purgecss end ignore */ 6 | 7 | @import 'tailwindcss/components'; 8 | 9 | @import 'tailwindcss/utilities'; 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const defaultTheme = require('tailwindcss/defaultTheme'); 3 | 4 | module.exports = { 5 | theme: { 6 | extend: { 7 | colors: { main: '#f9e635' }, 8 | fontFamily: { 9 | sans: ['Open Sans', ...defaultTheme.fontFamily.sans], 10 | }, 11 | }, 12 | }, 13 | variants: { opacity: ['disabled'], pointerEvents: ['disabled'] }, 14 | plugins: [], 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------