├── .env.example ├── .eslintignore ├── App.tsx ├── .github ├── image.png ├── workflows │ └── ci.yml └── expo.svg ├── assets ├── icon.png ├── splash.png └── adaptive-icon.png ├── .prettierrc ├── src ├── assets │ ├── avatar.png │ ├── avatar@2x.png │ ├── avatar@3x.png │ └── svg │ │ ├── heart.svg │ │ ├── bell.svg │ │ ├── cart.svg │ │ ├── trash.svg │ │ └── home.svg ├── @types │ └── index.d.ts ├── utils │ └── format-currency.ts ├── common │ └── statusbar-height.ts ├── components │ ├── row.tsx │ ├── avatar.tsx │ ├── icon-button.tsx │ ├── header.tsx │ ├── divider.tsx │ ├── ticket.tsx │ ├── input.tsx │ ├── go-back-button.tsx │ ├── button-text.tsx │ └── category-list.tsx ├── pages │ ├── notifications │ │ └── index.tsx │ ├── favorites │ │ ├── components │ │ │ ├── empty-list.tsx │ │ │ └── favorite-card.tsx │ │ └── index.tsx │ ├── cart │ │ ├── components │ │ │ ├── right-card-button.tsx │ │ │ └── card.tsx │ │ └── index.tsx │ ├── home │ │ ├── card │ │ │ └── index.tsx │ │ └── index.tsx │ └── description │ │ └── index.tsx ├── services │ ├── api.types.ts │ └── api.ts ├── routes │ ├── routes.type.ts │ ├── icons │ │ └── cart-icon.tsx │ └── app.routes.tsx ├── index.tsx └── store │ ├── favorites │ └── favorites-store.ts │ └── cart │ └── cart-store.ts ├── .expo-shared └── assets.json ├── .gitignore ├── tsconfig.json ├── .editorconfig ├── babel.config.js ├── eas.json ├── metro.config.js ├── app.json ├── LICENSE ├── .eslintrc.json ├── README.md ├── package.json └── db.json /.env.example: -------------------------------------------------------------------------------- 1 | API_URL=http://localhost:3333 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | node_modules 3 | build 4 | android 5 | ios 6 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { App } from '~/index'; 2 | 3 | export default App; 4 | -------------------------------------------------------------------------------- /.github/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/.github/image.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/assets/splash.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "printWidth": 90 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/src/assets/avatar.png -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/assets/adaptive-icon.png -------------------------------------------------------------------------------- /src/assets/avatar@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/src/assets/avatar@2x.png -------------------------------------------------------------------------------- /src/assets/avatar@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EliasGcf/coffee-delivery-app/HEAD/src/assets/avatar@3x.png -------------------------------------------------------------------------------- /.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | .env 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": ["./src/*"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | 3 | declare module '*.svg' { 4 | import React from 'react'; 5 | import { SvgProps } from 'react-native-svg'; 6 | 7 | const content: React.FC; 8 | export default content; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/format-currency.ts: -------------------------------------------------------------------------------- 1 | export function formatCurrency(value: number): string { 2 | const formattedValue = Intl.NumberFormat('en-EN', { 3 | style: 'currency', 4 | currency: 'USD', 5 | }).format(value); 6 | 7 | return formattedValue; 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /src/common/statusbar-height.ts: -------------------------------------------------------------------------------- 1 | import { Platform, StatusBar } from 'react-native'; 2 | import { getStatusBarHeight } from 'react-native-iphone-x-helper'; 3 | 4 | export const STATUSBAR_HEIGHT = 5 | Platform.OS === 'ios' 6 | ? getStatusBarHeight() + 20 7 | : Number(StatusBar.currentHeight) + 16; 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: [ 6 | 'inline-dotenv', 7 | ['root-import', { rootPathSuffix: './src', rootPathPrefix: '~/' }], 8 | 'react-native-reanimated/plugin', 9 | ], 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 0.50.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal" 9 | }, 10 | "preview": { 11 | "distribution": "internal" 12 | }, 13 | "production": {} 14 | }, 15 | "submit": { 16 | "production": {} 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/assets/svg/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/row.tsx: -------------------------------------------------------------------------------- 1 | import { StyleProp, View, ViewStyle } from 'react-native'; 2 | 3 | type RowProps = { 4 | center?: boolean; 5 | children: React.ReactNode; 6 | style?: StyleProp; 7 | }; 8 | 9 | export function Row({ children, center = true, style }: RowProps) { 10 | return ( 11 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/notifications/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View } from 'react-native'; 3 | 4 | export function Notifications() { 5 | return ( 6 | 7 | 15 | No notifications... 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/favorites/components/empty-list.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View } from 'react-native'; 3 | 4 | export function EmptyList() { 5 | return ( 6 | 7 | 15 | No favorites yet... 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require("expo/metro-config"); 2 | 3 | module.exports = (() => { 4 | const config = getDefaultConfig(__dirname); 5 | 6 | const { transformer, resolver } = config; 7 | 8 | config.transformer = { 9 | ...transformer, 10 | babelTransformerPath: require.resolve("react-native-svg-transformer"), 11 | }; 12 | config.resolver = { 13 | ...resolver, 14 | assetExts: resolver.assetExts.filter((ext) => ext !== "svg"), 15 | sourceExts: [...resolver.sourceExts, "svg"], 16 | }; 17 | 18 | return config; 19 | })(); 20 | -------------------------------------------------------------------------------- /src/services/api.types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | 3 | export declare namespace Api { 4 | export type RawCoffee = { 5 | id: string; 6 | name: string; 7 | simple_description: string; 8 | description: string; 9 | stars: number; 10 | price: number; 11 | image_url: string; 12 | categoryId: string; 13 | milk_options: string[]; 14 | }; 15 | 16 | export type Coffee = RawCoffee & { 17 | formatted_price: string; 18 | }; 19 | 20 | export type Category = { 21 | id: string; 22 | name: string; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Image, StyleSheet, View } from 'react-native'; 2 | 3 | import AvatarImg from '~/assets/avatar.png'; 4 | 5 | export function Avatar() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | const style = StyleSheet.create({ 13 | container: { 14 | height: 50, 15 | width: 50, 16 | borderRadius: 25, 17 | borderWidth: 1, 18 | borderColor: '#DCAA70', 19 | 20 | alignItems: 'center', 21 | justifyContent: 'center', 22 | }, 23 | 24 | avatar: { 25 | height: 44, 26 | width: 44, 27 | borderRadius: 22, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | import { BorderlessButton } from 'react-native-gesture-handler'; 3 | 4 | type IconButtonProps = { 5 | children: React.ReactNode; 6 | onPress?: () => void; 7 | }; 8 | 9 | export function IconButton({ onPress, children }: IconButtonProps) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | 17 | const styles = StyleSheet.create({ 18 | container: { 19 | height: 30, 20 | width: 30, 21 | borderRadius: 8, 22 | backgroundColor: '#EFE3C8', 23 | 24 | justifyContent: 'center', 25 | alignItems: 'center', 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/routes/routes.type.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | /* eslint-disable @typescript-eslint/no-namespace */ 3 | import { NavigatorScreenParams, RouteProp } from '@react-navigation/native'; 4 | 5 | import { Api } from '~/services/api.types'; 6 | 7 | type TabParamList = { 8 | Home: undefined; 9 | Cart: undefined; 10 | Favorites: undefined; 11 | Notifications: undefined; 12 | }; 13 | 14 | type RootStackParamList = { 15 | TabRoutes: NavigatorScreenParams; 16 | Description: { coffee: Api.Coffee }; 17 | }; 18 | 19 | export type DescriptionScreenRouteProp = RouteProp; 20 | 21 | declare global { 22 | namespace ReactNavigation { 23 | interface RootParamList extends RootStackParamList {} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Coffee Delivery", 4 | "slug": "coffee-delivery", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "splash": { 9 | "image": "./assets/splash.png", 10 | "resizeMode": "contain", 11 | "backgroundColor": "#201520" 12 | }, 13 | "updates": { 14 | "fallbackToCacheTimeout": 0 15 | }, 16 | "assetBundlePatterns": ["**/*"], 17 | "ios": { 18 | "supportsTablet": true, 19 | "bundleIdentifier": "com.eliasgcf.coffee-delivery" 20 | }, 21 | "android": { 22 | "package": "com.eliasgcf.coffee_delivery", 23 | "adaptiveIcon": { 24 | "foregroundImage": "./assets/adaptive-icon.png", 25 | "backgroundColor": "#201520" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/cart/components/right-card-button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; 3 | import { RectButton } from 'react-native-gesture-handler'; 4 | 5 | import TrashSvg from '~/assets/svg/trash.svg'; 6 | 7 | type RightCardButtonProps = { 8 | style?: StyleProp; 9 | onPress?: () => void; 10 | }; 11 | 12 | export function RightCardButton({ style, onPress }: RightCardButtonProps) { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | const styles = StyleSheet.create({ 21 | container: { 22 | backgroundColor: '#C94C4C', 23 | borderRadius: 15, 24 | width: 55, 25 | alignItems: 'center', 26 | justifyContent: 'center', 27 | marginLeft: 16, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Text, View } from 'react-native'; 2 | 3 | import { Avatar } from '~/components/avatar'; 4 | 5 | export function Header() { 6 | return ( 7 | 8 | 9 | déjà 10 | Brew 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | flexDirection: 'row', 21 | alignItems: 'center', 22 | justifyContent: 'space-between', 23 | }, 24 | 25 | textFirst: { color: '#877C74', fontSize: 36, fontFamily: 'Rosarivo_400Regular' }, 26 | 27 | textSecond: { 28 | fontSize: 48, 29 | color: '#EFE3C8', 30 | fontFamily: 'Rosarivo_400Regular', 31 | marginTop: -20, 32 | marginLeft: 5, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import { Api } from '~/services/api.types'; 4 | 5 | import { formatCurrency } from '~/utils/format-currency'; 6 | 7 | export const api = axios.create({ 8 | baseURL: process.env.API_URL, 9 | }); 10 | 11 | async function getCoffees({ 12 | categoryId, 13 | }: { 14 | categoryId?: string; 15 | }): Promise<{ data: Api.Coffee[] }> { 16 | try { 17 | const response = await api.get('coffees', { 18 | params: { categoryId }, 19 | }); 20 | 21 | const data = response.data.map((coffee) => { 22 | return { ...coffee, formatted_price: formatCurrency(coffee.price) }; 23 | }); 24 | 25 | return { data }; 26 | } catch (error) { 27 | // eslint-disable-next-line no-console 28 | console.error(error); 29 | return { data: [] }; 30 | } 31 | } 32 | 33 | export function useApi() { 34 | return { 35 | getCoffees, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/divider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleProp, ViewStyle, Dimensions } from 'react-native'; 3 | import Svg, { G, Rect } from 'react-native-svg'; 4 | 5 | type DividerProps = { 6 | style?: StyleProp; 7 | }; 8 | 9 | export function Divider({ style }: DividerProps) { 10 | const { width } = Dimensions.get('screen'); 11 | const spacing = 14; 12 | 13 | const dashes = new Array(Math.floor(width / spacing) + 1).fill(null); 14 | 15 | return ( 16 | 17 | 18 | {dashes.map((_, index) => ( 19 | 28 | ))} 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/ticket.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, View } from 'react-native'; 2 | 3 | type TicketProps = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | export function Ticket({ children }: TicketProps) { 8 | return ( 9 | 10 | 11 | 12 | {children} 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | const styles = StyleSheet.create({ 20 | container: { 21 | backgroundColor: '#38232A', 22 | height: 40, 23 | flexDirection: 'row', 24 | justifyContent: 'space-between', 25 | alignItems: 'center', 26 | }, 27 | 28 | dot: { 29 | height: 19, 30 | width: 19, 31 | backgroundColor: '#201520', 32 | borderRadius: 19 / 2, 33 | margin: -19 / 2, 34 | }, 35 | 36 | main: { 37 | flex: 1, 38 | paddingHorizontal: 30, 39 | flexDirection: 'row', 40 | alignItems: 'center', 41 | justifyContent: 'space-between', 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/input.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesome } from '@expo/vector-icons'; 2 | import { StyleSheet, TextInput, View, TextInputProps } from 'react-native'; 3 | 4 | type InputPros = TextInputProps; 5 | 6 | export function Input({ ...rest }: InputPros) { 7 | return ( 8 | 9 | 10 | 16 | 17 | ); 18 | } 19 | 20 | const style = StyleSheet.create({ 21 | container: { 22 | height: 40, 23 | flexDirection: 'row', 24 | alignItems: 'center', 25 | backgroundColor: '#171017', 26 | borderRadius: 10, 27 | paddingHorizontal: 20, 28 | }, 29 | 30 | textInput: { 31 | flex: 1, 32 | fontSize: 14, 33 | fontFamily: 'Rosarivo_400Regular', 34 | marginLeft: 20, 35 | color: '#877C74', 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Expo and build APK 2 | 3 | on: push 4 | 5 | jobs: 6 | publish: 7 | name: Publish and Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: 🏗 Setup repo 11 | uses: actions/checkout@v2 12 | 13 | - name: 🏗 Setup Node 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 16.x 17 | 18 | - name: 🏗 Setup Expo and EAS 19 | uses: expo/expo-github-action@v7 20 | with: 21 | eas-version: latest 22 | expo-version: latest 23 | packager: yarn 24 | token: ${{ secrets.EXPO_TOKEN }} 25 | 26 | - name: 📦 Install dependencies 27 | run: yarn 28 | 29 | - name: 🔓 Create env file 30 | run: | 31 | touch .env 32 | echo API_URL=${{ secrets.API_URL }} >> .env 33 | 34 | - name: 🚀 Publish app 35 | run: expo publish --non-interactive 36 | 37 | - name: 🔧 Build app 38 | run: eas build -p android --no-wait --non-interactive 39 | -------------------------------------------------------------------------------- /src/pages/favorites/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FlatList, StyleSheet, View } from 'react-native'; 3 | 4 | import { EmptyList } from '~/pages/favorites/components/empty-list'; 5 | import { FavoriteCard } from '~/pages/favorites/components/favorite-card'; 6 | 7 | import { useFavorites } from '~/store/favorites/favorites-store'; 8 | 9 | export function Favorites() { 10 | const items = useFavorites((state) => state.items); 11 | 12 | return ( 13 | 14 | coffee.id} 18 | contentContainerStyle={items.length === 0 ? { flex: 1 } : undefined} 19 | ListEmptyComponent={EmptyList} 20 | renderItem={({ item }) => ( 21 | 22 | )} 23 | /> 24 | 25 | ); 26 | } 27 | 28 | const styles = StyleSheet.create({ 29 | container: { 30 | flex: 1, 31 | paddingHorizontal: 16, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 EliasGcf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'intl'; 2 | import 'intl/locale-data/jsonp/en'; 3 | 4 | import { 5 | OpenSans_400Regular, 6 | OpenSans_700Bold, 7 | OpenSans_600SemiBold, 8 | } from '@expo-google-fonts/open-sans'; 9 | import { Rosarivo_400Regular } from '@expo-google-fonts/rosarivo'; 10 | import { NavigationContainer } from '@react-navigation/native'; 11 | import AppLoading from 'expo-app-loading'; 12 | import { useFonts } from 'expo-font'; 13 | import { StatusBar } from 'expo-status-bar'; 14 | import { GestureHandlerRootView } from 'react-native-gesture-handler'; 15 | 16 | import { Routes } from '~/routes/app.routes'; 17 | 18 | export function App() { 19 | const [isFontsLoaded] = useFonts({ 20 | Rosarivo_400Regular, 21 | 22 | OpenSans_400Regular, 23 | OpenSans_600SemiBold, 24 | OpenSans_700Bold, 25 | }); 26 | 27 | if (!isFontsLoaded) { 28 | return ; 29 | } 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/routes/icons/cart-icon.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, MotiView } from 'moti'; 2 | import React from 'react'; 3 | import { StyleSheet, View } from 'react-native'; 4 | 5 | import CartSvg from '~/assets/svg/cart.svg'; 6 | 7 | import { useCart } from '~/store/cart/cart-store'; 8 | 9 | type CartIconProps = { 10 | // focused: boolean; 11 | color: string; 12 | // size: number; 13 | }; 14 | 15 | export function CartIcon({ color }: CartIconProps) { 16 | const showDot = useCart((state) => state.showDot); 17 | 18 | return ( 19 | 20 | 21 | 22 | {showDot && ( 23 | 29 | )} 30 | 31 | 32 | ); 33 | } 34 | 35 | const styles = StyleSheet.create({ 36 | dot: { 37 | height: 8, 38 | width: 8, 39 | backgroundColor: '#C94C4C', 40 | position: 'absolute', 41 | borderRadius: 4, 42 | right: 0, 43 | top: 0, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/go-back-button.tsx: -------------------------------------------------------------------------------- 1 | import { AntDesign } from '@expo/vector-icons'; 2 | import { useNavigation } from '@react-navigation/native'; 3 | import { BlurView } from 'expo-blur'; 4 | import React from 'react'; 5 | import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; 6 | import { BorderlessButton } from 'react-native-gesture-handler'; 7 | 8 | type GoBackButtonProps = { 9 | style?: StyleProp; 10 | }; 11 | 12 | export function GoBackButton({ style }: GoBackButtonProps) { 13 | const navigation = useNavigation(); 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | const styles = StyleSheet.create({ 25 | container: { 26 | width: 40, 27 | height: 40, 28 | borderRadius: 20, 29 | overflow: 'hidden', 30 | }, 31 | 32 | blurContainer: { 33 | borderRadius: 20, 34 | height: '100%', 35 | width: '100%', 36 | alignItems: 'center', 37 | justifyContent: 'center', 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/store/favorites/favorites-store.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage'; 2 | import create from 'zustand'; 3 | import { persist } from 'zustand/middleware'; 4 | 5 | import { Api } from '~/services/api.types'; 6 | 7 | // AsyncStorage.clear(); 8 | 9 | type FavoritesStore = { 10 | items: Api.Coffee[]; 11 | add: (coffee: Api.Coffee) => void; 12 | remove: (id: string) => void; 13 | isFavorite: (id: string) => boolean; 14 | }; 15 | 16 | export const useFavorites = create( 17 | persist( 18 | (set, get) => ({ 19 | items: [], 20 | 21 | add: (coffee) => { 22 | const item = get().items.find((fitem) => fitem.id === coffee.id); 23 | 24 | if (item) return; 25 | 26 | set((state) => ({ items: [...state.items, coffee] })); 27 | }, 28 | 29 | remove: (id) => { 30 | set((state) => ({ items: state.items.filter((item) => item.id !== id) })); 31 | }, 32 | 33 | isFavorite: (id) => { 34 | return !!get().items.find((item) => item.id === id); 35 | }, 36 | }), 37 | { 38 | name: '@coffee/favorites', 39 | getStorage: () => AsyncStorage, 40 | } 41 | ) 42 | ); 43 | -------------------------------------------------------------------------------- /src/components/button-text.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StyleProp, 3 | StyleSheet, 4 | Text, 5 | TextStyle, 6 | TouchableOpacity, 7 | ViewStyle, 8 | } from 'react-native'; 9 | 10 | type ButtonTextProps = { 11 | text: string; 12 | outline?: boolean; 13 | onPress?: () => void; 14 | disabled?: boolean; 15 | style?: StyleProp; 16 | textStyle?: StyleProp; 17 | }; 18 | 19 | export function ButtonText({ 20 | outline = false, 21 | text, 22 | onPress, 23 | disabled = false, 24 | style, 25 | textStyle, 26 | }: ButtonTextProps) { 27 | return ( 28 | 39 | 47 | {text} 48 | 49 | 50 | ); 51 | } 52 | 53 | const styles = StyleSheet.create({ 54 | container: { 55 | backgroundColor: '#EFE3C8', 56 | height: 34, 57 | alignItems: 'center', 58 | justifyContent: 'center', 59 | borderRadius: 10, 60 | paddingHorizontal: 20, 61 | 62 | borderWidth: 1, 63 | borderColor: '#EFE3C8', 64 | }, 65 | 66 | containerOutline: { 67 | backgroundColor: 'transparent', 68 | }, 69 | 70 | text: { 71 | fontSize: 14, 72 | fontFamily: 'Rosarivo_400Regular', 73 | color: '#201520', 74 | }, 75 | 76 | textOutline: { 77 | color: '#EFE3C8', 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "es2021": true }, 3 | "globals": { 4 | "__DEV__": "readonly" 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "airbnb", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { "jsx": true }, 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["react", "@typescript-eslint", "react-hooks", "prettier", "import-helpers", "unused-imports"], 19 | "rules": { 20 | "prettier/prettier": "error", 21 | "camelcase": "off", 22 | "react-hooks/rules-of-hooks": "error", 23 | "react-hooks/exhaustive-deps": "warn", 24 | "react/react-in-jsx-scope": "off", 25 | "react/jsx-props-no-spreading": "off", 26 | "react/jsx-no-bind": "off", 27 | "react/style-prop-object": "off", 28 | "react/jsx-filename-extension": [1, { "extensions": [".tsx"] }], 29 | "react/require-default-props": "off", 30 | "react/no-array-index-key": "off", 31 | "no-use-before-define": "off", 32 | "unused-imports/no-unused-imports": "error", 33 | "import/prefer-default-export": "off", 34 | "import/extensions": ["error", "ignorePackages", { "ts": "never", "tsx": "never" }], 35 | "import-helpers/order-imports": [ 36 | "warn", 37 | { 38 | "newlinesBetween": "always", 39 | "groups": [ 40 | ["/^react$/", "module"], 41 | "/^~/assets/", 42 | "/^~/common/", 43 | "/^~/components/", 44 | "/^~/pages/", 45 | "/^~/routes/", 46 | "/^~/services/", 47 | "/^~/store/", 48 | "/^~/utils/", 49 | ["parent", "sibling"], 50 | "index" 51 | ], 52 | "alphabetize": { "order": "asc", "ignoreCase": true } 53 | } 54 | ] 55 | 56 | }, 57 | "settings": { 58 | "import/resolver": { 59 | "typescript": {} 60 | 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/assets/svg/bell.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.github/expo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/svg/cart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coffee Delivery ☕️ 2 | 3 |

4 | Expo Go 5 |

6 | 7 |

8 | Layout • 9 | Technologies • 10 | Getting started • 11 | License 12 |

13 | 14 |

15 | Coffee Delivery Layout Image 16 |

17 | 18 | ## 🔖 Layout 19 | 20 | The author of this layout is [Nickelfox Design](https://www.figma.com/@Nickelfox). You can view the project in this [link](https://www.figma.com/community/file/1050295107596166499). 21 | 22 | ## 🚀 Technologies 23 | 24 | - [Expo](https://expo.io/) 25 | - [ReactJS](https://reactjs.org/) 26 | - [React Native](https://reactnative.dev/) 27 | - [React Navigation](https://reactnavigation.org/) 28 | - [React Native Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/) 29 | - [TypeScript](https://www.typescriptlang.org/) 30 | - [AsyncStorage](https://react-native-async-storage.github.io/async-storage/) 31 | - [Zustand](https://github.com/pmndrs/zustand) 32 | 33 | ## 💻 Getting started 34 | 35 | ### Requirements 36 | 37 | - [Node.js](https://nodejs.org/en/) 38 | - [Yarn](https://classic.yarnpkg.com/) or [npm](https://www.npmjs.com/package/npm) 39 | - [Expo CLI](https://docs.expo.dev/workflow/expo-cli) 40 | 41 | **Clone the project and access the folder** 42 | 43 | ```bash 44 | git clone https://github.com/EliasGcf/coffee-delivery-app.git && cd coffee-delivery-app 45 | ``` 46 | 47 | **Follow the steps below** 48 | 49 | ```bash 50 | # Install the dependencies 51 | $ yarn 52 | 53 | # Make a copy of '.env.example' to '.env' 54 | $ cp .env.example .env 55 | 56 | # Run the JSON Server 57 | $ yarn json:sever 58 | 59 | # If you are going to emulate with android, run this command 60 | $ yarn android 61 | 62 | # If you are going to emulate with ios, run this command 63 | $ yarn ios 64 | ``` 65 | 66 | ## 📝 License 67 | 68 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 69 | 70 | --- 71 | 72 |

73 | Made with 💜 by Elias Gabriel 74 |

75 | -------------------------------------------------------------------------------- /src/assets/svg/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/svg/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coffee-delivery", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "node_modules/expo/AppEntry.js", 6 | "scripts": { 7 | "android": "expo start --android", 8 | "eject": "expo eject", 9 | "ios": "expo start --ios", 10 | "start": "expo start", 11 | "json:server": "json-server --delay 500 --port 3333 --watch db.json" 12 | }, 13 | "dependencies": { 14 | "@expo-google-fonts/open-sans": "^0.2.2", 15 | "@expo-google-fonts/rosarivo": "^0.2.2", 16 | "@expo/vector-icons": "^12.0.5", 17 | "@react-native-async-storage/async-storage": "~1.15.0", 18 | "@react-navigation/bottom-tabs": "^6.3.1", 19 | "@react-navigation/native": "^6.0.10", 20 | "@react-navigation/native-stack": "^6.6.1", 21 | "axios": "^0.26.1", 22 | "babel-plugin-inline-dotenv": "^1.7.0", 23 | "expo": "~44.0.0", 24 | "expo-app-loading": "~1.3.0", 25 | "expo-blur": "~11.0.0", 26 | "expo-font": "~10.0.4", 27 | "expo-status-bar": "~1.2.0", 28 | "expo-updates": "~0.11.6", 29 | "intl": "^1.2.5", 30 | "moti": "^0.17.1", 31 | "react": "17.0.1", 32 | "react-native": "0.64.3", 33 | "react-native-gesture-handler": "~2.1.0", 34 | "react-native-iphone-x-helper": "^1.3.1", 35 | "react-native-reanimated": "~2.3.1", 36 | "react-native-safe-area-context": "3.3.2", 37 | "react-native-screens": "~3.10.1", 38 | "react-native-svg": "12.1.1", 39 | "zustand": "^3.7.2" 40 | }, 41 | "devDependencies": { 42 | "@babel/core": "^7.12.9", 43 | "@types/react": "~17.0.21", 44 | "@types/react-native": "~0.64.12", 45 | "@typescript-eslint/eslint-plugin": "^5.17.0", 46 | "@typescript-eslint/parser": "^5.17.0", 47 | "babel-plugin-root-import": "^6.6.0", 48 | "eslint": "^8.2.0", 49 | "eslint-config-airbnb": "^19.0.4", 50 | "eslint-config-prettier": "^8.5.0", 51 | "eslint-import-resolver-typescript": "^2.7.0", 52 | "eslint-plugin-import": "^2.25.3", 53 | "eslint-plugin-import-helpers": "^1.2.1", 54 | "eslint-plugin-jsx-a11y": "^6.5.1", 55 | "eslint-plugin-prettier": "^4.0.0", 56 | "eslint-plugin-react": "^7.28.0", 57 | "eslint-plugin-react-hooks": "^4.3.0", 58 | "eslint-plugin-unused-imports": "^2.0.0", 59 | "json-server": "^0.17.0", 60 | "prettier": "^2.6.1", 61 | "react-native-svg-transformer": "^1.0.0", 62 | "typescript": "~4.3.5" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/favorites/components/favorite-card.tsx: -------------------------------------------------------------------------------- 1 | import { AntDesign } from '@expo/vector-icons'; 2 | import { useNavigation } from '@react-navigation/native'; 3 | import { Image, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 4 | import { BorderlessButton, TouchableOpacity } from 'react-native-gesture-handler'; 5 | 6 | import { Api } from '~/services/api.types'; 7 | 8 | import { useFavorites } from '~/store/favorites/favorites-store'; 9 | 10 | type CartCardProps = { 11 | coffee: Api.Coffee; 12 | style?: StyleProp; 13 | }; 14 | 15 | export function FavoriteCard({ coffee, style }: CartCardProps) { 16 | const navigation = useNavigation(); 17 | const removeFromFavorites = useFavorites((state) => state.remove); 18 | 19 | return ( 20 | navigation.navigate('Description', { coffee })} 24 | > 25 | 26 | 27 | 28 | {coffee.name} 29 | {coffee.simple_description} 30 | {coffee.formatted_price} 31 | 32 | 33 | removeFromFavorites(coffee.id)}> 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | const styles = StyleSheet.create({ 41 | card: { 42 | padding: 12, 43 | borderRadius: 15, 44 | backgroundColor: '#362C36', 45 | flexDirection: 'row', 46 | alignItems: 'center', 47 | }, 48 | 49 | image: { 50 | height: 72, 51 | width: 72, 52 | borderRadius: 15, 53 | }, 54 | 55 | textWrapper: { 56 | justifyContent: 'space-between', 57 | marginLeft: 12, 58 | flex: 1, 59 | paddingRight: 8, 60 | }, 61 | 62 | itemText: { 63 | fontSize: 14, 64 | fontFamily: 'Rosarivo_400Regular', 65 | color: '#fff', 66 | }, 67 | 68 | subItemText: { 69 | fontSize: 12, 70 | fontFamily: 'Rosarivo_400Regular', 71 | color: '#fff', 72 | }, 73 | 74 | amountText: { 75 | fontSize: 16, 76 | fontFamily: 'OpenSans_600SemiBold', 77 | color: '#fff', 78 | }, 79 | 80 | quantityText: { 81 | marginHorizontal: 10, 82 | fontFamily: 'Rosarivo_400Regular', 83 | color: '#fff', 84 | fontSize: 20, 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /src/components/category-list.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; 3 | 4 | import { Api } from '~/services/api.types'; 5 | 6 | type CategoryListProps = { 7 | height: number; 8 | categories: Api.Category[]; 9 | selectedCategory?: Api.Category | null; 10 | onChange: (category: Api.Category) => void; 11 | }; 12 | 13 | export function CategoryList({ 14 | height, 15 | categories, 16 | selectedCategory, 17 | onChange, 18 | }: CategoryListProps) { 19 | const [sizes, setCategoriesBarSizes] = useState({ height: 0, width: 0 }); 20 | 21 | return ( 22 | { 35 | setCategoriesBarSizes({ 36 | width: event.nativeEvent.layout.width, 37 | height: event.nativeEvent.layout.height, 38 | }); 39 | }} 40 | > 41 | 46 | {categories.map((category) => ( 47 | onChange(category)} 52 | > 53 | 59 | {category.name} 60 | 61 | 62 | ))} 63 | 64 | 65 | ); 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | container: { 70 | height: 38, 71 | position: 'absolute', 72 | zIndex: 1, 73 | left: 0, 74 | backgroundColor: '#38232A', 75 | borderTopStartRadius: 10, 76 | flexDirection: 'row', 77 | }, 78 | 79 | scrollViewContentContainer: { 80 | paddingHorizontal: 34, 81 | }, 82 | 83 | button: { 84 | marginRight: 40, 85 | justifyContent: 'center', 86 | }, 87 | 88 | text: { 89 | fontSize: 14, 90 | fontFamily: 'Rosarivo_400Regular', 91 | transform: [{ rotate: '180deg' }], 92 | color: '#938379', 93 | }, 94 | 95 | selectedText: { 96 | color: '#EFE3C8', 97 | transform: [{ rotate: '180deg' }, { scale: 1.15 }], 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /src/routes/app.routes.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-unstable-nested-components */ 2 | /* eslint-disable @typescript-eslint/no-namespace */ 3 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 4 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 5 | 6 | import BellSvg from '~/assets/svg/bell.svg'; 7 | import HeartSvg from '~/assets/svg/heart.svg'; 8 | import HomeSvg from '~/assets/svg/home.svg'; 9 | 10 | import { Cart } from '~/pages/cart'; 11 | import { Description } from '~/pages/description'; 12 | import { Favorites } from '~/pages/favorites'; 13 | import { Home } from '~/pages/home'; 14 | import { Notifications } from '~/pages/notifications'; 15 | 16 | import { CartIcon } from '~/routes/icons/cart-icon'; 17 | 18 | const Tab = createBottomTabNavigator(); 19 | const Stack = createNativeStackNavigator(); 20 | 21 | function TabRoutes() { 22 | return ( 23 | 54 | , 60 | }} 61 | /> 62 | 63 | 70 | 71 | , 76 | }} 77 | /> 78 | 79 | , 84 | }} 85 | /> 86 | 87 | ); 88 | } 89 | 90 | export function Routes() { 91 | return ( 92 | 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/pages/cart/components/card.tsx: -------------------------------------------------------------------------------- 1 | import { Feather } from '@expo/vector-icons'; 2 | import { Image, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 3 | import { Swipeable } from 'react-native-gesture-handler'; 4 | 5 | import { IconButton } from '~/components/icon-button'; 6 | import { Row } from '~/components/row'; 7 | 8 | import { RightCardButton } from '~/pages/cart/components/right-card-button'; 9 | 10 | import { CartItem, useCart } from '~/store/cart/cart-store'; 11 | 12 | type CartCardProps = { 13 | item: CartItem; 14 | style?: StyleProp; 15 | }; 16 | 17 | export function CartCard({ item, style }: CartCardProps) { 18 | const decrementCoffeeInCart = useCart((state) => state.decrement); 19 | const removeCoffeeFromCart = useCart((state) => state.remove); 20 | const addCoffeeToCart = useCart((state) => state.add); 21 | 22 | return ( 23 | ( 25 | removeCoffeeFromCart(item.coffee)} 28 | /> 29 | )} 30 | > 31 | 32 | 37 | 38 | 39 | {item.coffee.name} 40 | {item.coffee.simple_description} 41 | {item.coffee.formatted_price} 42 | 43 | 44 | 45 | decrementCoffeeInCart(item.coffee)}> 46 | 47 | 48 | 49 | {item.quantity} 50 | 51 | addCoffeeToCart(item.coffee)}> 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | const styles = StyleSheet.create({ 61 | card: { 62 | padding: 12, 63 | borderRadius: 15, 64 | backgroundColor: '#362C36', 65 | flexDirection: 'row', 66 | alignItems: 'center', 67 | }, 68 | 69 | image: { 70 | height: 72, 71 | width: 72, 72 | borderRadius: 15, 73 | }, 74 | 75 | textWrapper: { 76 | justifyContent: 'space-between', 77 | marginLeft: 12, 78 | flex: 1, 79 | paddingRight: 8, 80 | }, 81 | 82 | itemText: { 83 | fontSize: 14, 84 | fontFamily: 'Rosarivo_400Regular', 85 | color: '#fff', 86 | }, 87 | 88 | subItemText: { 89 | fontSize: 12, 90 | fontFamily: 'Rosarivo_400Regular', 91 | color: '#fff', 92 | }, 93 | 94 | amountText: { 95 | fontSize: 16, 96 | fontFamily: 'OpenSans_600SemiBold', 97 | color: '#fff', 98 | }, 99 | 100 | quantityWrapper: { 101 | backgroundColor: '#463D46', 102 | borderRadius: 8, 103 | height: 30, 104 | }, 105 | 106 | quantityText: { 107 | marginHorizontal: 10, 108 | fontFamily: 'Rosarivo_400Regular', 109 | color: '#fff', 110 | fontSize: 20, 111 | }, 112 | }); 113 | -------------------------------------------------------------------------------- /src/pages/home/card/index.tsx: -------------------------------------------------------------------------------- 1 | import { Feather, FontAwesome } from '@expo/vector-icons'; 2 | import { Image, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; 3 | import { BorderlessButton, TouchableOpacity } from 'react-native-gesture-handler'; 4 | 5 | import { Api } from '~/services/api.types'; 6 | 7 | import { useCart } from '~/store/cart/cart-store'; 8 | 9 | type HomeCardProps = { 10 | coffee: Api.Coffee; 11 | style?: StyleProp; 12 | onPress?: () => void; 13 | }; 14 | 15 | export function HomeCard({ coffee, style, onPress }: HomeCardProps) { 16 | const addItemToCart = useCart((state) => state.add); 17 | const setShowCartDot = useCart((state) => state.setShowDot); 18 | 19 | function handleOnPress() { 20 | addItemToCart(coffee); 21 | setShowCartDot(true); 22 | } 23 | 24 | return ( 25 | 30 | 31 | 36 | {coffee.name} 37 | 38 | 39 | 40 | {coffee.stars} 41 | 42 | 43 | 44 | 45 | 46 | {coffee.formatted_price} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | 57 | const styles = StyleSheet.create({ 58 | container: { 59 | maxWidth: 135, 60 | padding: 12, 61 | 62 | borderRadius: 12.61, 63 | 64 | backgroundColor: '#362C36', 65 | 66 | justifyContent: 'space-between', 67 | }, 68 | 69 | image: { 70 | width: 111, 71 | height: 111, 72 | 73 | backgroundColor: '#463D46', 74 | 75 | borderRadius: 15, 76 | borderTopLeftRadius: 18, 77 | }, 78 | 79 | coffeeName: { 80 | fontSize: 14, 81 | color: '#fff', 82 | fontFamily: 'Rosarivo_400Regular', 83 | 84 | marginTop: 8, 85 | }, 86 | 87 | starBadgeContainer: { 88 | height: 20, 89 | position: 'absolute', 90 | top: 0, 91 | left: 0, 92 | backgroundColor: '#353131', 93 | paddingHorizontal: 8, 94 | borderTopLeftRadius: 15, 95 | borderBottomRightRadius: 15, 96 | flexDirection: 'row', 97 | alignItems: 'center', 98 | }, 99 | 100 | starText: { 101 | color: '#fff', 102 | fontFamily: 'Rosarivo_400Regular', 103 | fontSize: 10, 104 | marginLeft: 4, 105 | lineHeight: 10, 106 | }, 107 | 108 | footer: { 109 | flexDirection: 'row', 110 | height: 39, 111 | borderRadius: 12, 112 | marginTop: 13, 113 | 114 | alignItems: 'center', 115 | justifyContent: 'space-between', 116 | 117 | backgroundColor: '#463D46', 118 | }, 119 | 120 | amountCenter: { 121 | flex: 1, 122 | alignItems: 'center', 123 | }, 124 | 125 | amount: { 126 | fontSize: 16, 127 | color: '#fff', 128 | fontFamily: 'OpenSans_600SemiBold', 129 | }, 130 | 131 | amountPlusContainer: { 132 | backgroundColor: '#EFE3C8', 133 | height: 39, 134 | width: 39, 135 | borderRadius: 12, 136 | alignItems: 'center', 137 | justifyContent: 'center', 138 | }, 139 | }); 140 | -------------------------------------------------------------------------------- /src/store/cart/cart-store.ts: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage'; 2 | import create, { GetState, SetState, StoreApi } from 'zustand'; 3 | import { persist } from 'zustand/middleware'; 4 | 5 | import { Api } from '~/services/api.types'; 6 | 7 | import { formatCurrency } from '~/utils/format-currency'; 8 | 9 | // AsyncStorage.clear(); 10 | 11 | export type CartItem = { 12 | coffee: Api.Coffee; 13 | formattedPrice: string; 14 | quantity: number; 15 | }; 16 | 17 | type CartStore = { 18 | showDot: boolean; 19 | items: CartItem[]; 20 | amount: number; 21 | clear: () => void; 22 | add: (coffee: Api.Coffee) => void; 23 | decrement: (coffee: Api.Coffee) => void; 24 | remove: (coffee: Api.Coffee) => void; 25 | setShowDot: (showDot: boolean) => void; 26 | }; 27 | 28 | const middleware = 29 | (config: any) => 30 | (set: SetState, get: GetState, api: StoreApi) => 31 | config( 32 | (args: CartStore) => { 33 | const amount = get().items.reduce((acc, item) => { 34 | return acc + item.quantity * item.coffee.price; 35 | }, 0); 36 | 37 | set({ amount }); 38 | set(args); 39 | }, 40 | () => { 41 | const amount = get().items.reduce((acc, item) => { 42 | return acc + item.quantity * item.coffee.price; 43 | }, 0); 44 | 45 | set({ amount }); 46 | 47 | return get(); 48 | }, 49 | api 50 | ); 51 | 52 | export const useCart = create( 53 | middleware( 54 | persist( 55 | (set) => ({ 56 | showDot: false, 57 | 58 | items: [], 59 | 60 | amount: 0, 61 | 62 | clear: () => { 63 | set({ items: [] }); 64 | }, 65 | 66 | add: (coffee: Api.Coffee) => { 67 | set((state) => { 68 | const item = state.items.find((fItem) => fItem.coffee.id === coffee.id); 69 | 70 | if (item) { 71 | return { 72 | items: state.items.map((fItem) => { 73 | if (fItem.coffee.id === coffee.id) { 74 | return { ...fItem, quantity: fItem.quantity + 1 }; 75 | } 76 | 77 | return fItem; 78 | }), 79 | }; 80 | } 81 | 82 | return { 83 | items: [ 84 | ...state.items, 85 | { coffee, quantity: 1, formattedPrice: formatCurrency(coffee.price) }, 86 | ], 87 | }; 88 | }); 89 | }, 90 | 91 | decrement: (coffee: Api.Coffee) => { 92 | set((state) => { 93 | const item = state.items.find((fItem) => fItem.coffee.id === coffee.id); 94 | 95 | if (item?.quantity === 1) { 96 | return { 97 | items: state.items.filter((fItem) => fItem.coffee.id !== coffee.id), 98 | }; 99 | } 100 | 101 | return { 102 | items: state.items.map((fItem) => { 103 | if (fItem.coffee.id === coffee.id) { 104 | return { ...fItem, quantity: fItem.quantity - 1 }; 105 | } 106 | 107 | return fItem; 108 | }), 109 | }; 110 | }); 111 | }, 112 | 113 | remove: (coffee: Api.Coffee) => { 114 | set((state) => { 115 | return { 116 | items: state.items.filter((item) => item.coffee.id !== coffee.id), 117 | }; 118 | }); 119 | }, 120 | 121 | setShowDot: (showDot: boolean) => { 122 | set({ showDot }); 123 | }, 124 | }), 125 | { 126 | name: '@coffee/cart', 127 | getStorage: () => AsyncStorage, 128 | } 129 | ) 130 | ) 131 | ); 132 | -------------------------------------------------------------------------------- /src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from '@react-navigation/native'; 2 | import { useMemo, useEffect, useState } from 'react'; 3 | import { 4 | ActivityIndicator, 5 | FlatList, 6 | Platform, 7 | StyleSheet, 8 | Text, 9 | View, 10 | } from 'react-native'; 11 | 12 | import { STATUSBAR_HEIGHT } from '~/common/statusbar-height'; 13 | 14 | import { CategoryList } from '~/components/category-list'; 15 | import { Header } from '~/components/header'; 16 | import { Input } from '~/components/input'; 17 | 18 | import { HomeCard } from '~/pages/home/card'; 19 | 20 | import { api, useApi } from '~/services/api'; 21 | import { Api } from '~/services/api.types'; 22 | 23 | function EmptyList({ isLoading }: { isLoading: boolean }) { 24 | return ( 25 | 26 | {isLoading ? ( 27 | 31 | ) : ( 32 | 35 | Found no coffees... 36 | 37 | )} 38 | 39 | ); 40 | } 41 | 42 | export function Home() { 43 | const [coffees, setCoffees] = useState([]); 44 | const [categories, setCategories] = useState([]); 45 | 46 | const [isLoading, setIsLoading] = useState(true); 47 | const [searchValue, setSearchValue] = useState(''); 48 | const [mainContainerHeight, setMainContainerHeight] = useState(0); 49 | const [selectedCategory, setSelectedCategory] = useState(null); 50 | 51 | const navigation = useNavigation(); 52 | 53 | const { getCoffees } = useApi(); 54 | 55 | const filteredCoffees = useMemo(() => { 56 | return coffees.filter((coffee) => 57 | coffee.name.toLowerCase().includes(searchValue.toLowerCase()) 58 | ); 59 | }, [coffees, searchValue]); 60 | 61 | useEffect(() => { 62 | async function loadData() { 63 | const categoriesResponse = await api.get('categories'); 64 | 65 | const firstCategory = categoriesResponse.data[0]; 66 | 67 | setCategories(categoriesResponse.data); 68 | setSelectedCategory(firstCategory); 69 | } 70 | 71 | loadData(); 72 | }, []); 73 | 74 | useEffect(() => { 75 | if (!selectedCategory) return; 76 | 77 | setIsLoading(true); 78 | setCoffees([]); 79 | setSearchValue(''); 80 | 81 | getCoffees({ categoryId: selectedCategory.id }) 82 | .then((response) => setCoffees(response.data)) 83 | .finally(() => setIsLoading(false)); 84 | }, [getCoffees, selectedCategory]); 85 | 86 | return ( 87 | 88 | 89 |
90 | 91 | 92 | 93 | setMainContainerHeight(event.nativeEvent.layout.height)} 96 | > 97 | setSelectedCategory(data)} 101 | selectedCategory={selectedCategory} 102 | /> 103 | 104 | item.id} 110 | contentContainerStyle={[ 111 | coffeeListStyle.contentContainer, 112 | filteredCoffees.length === 0 ? { flex: 1 } : undefined, 113 | ]} 114 | ListEmptyComponent={} 115 | columnWrapperStyle={{ marginBottom: 16 }} 116 | renderItem={({ item, index }) => ( 117 | navigation.navigate('Description', { coffee: item })} 120 | style={{ 121 | marginRight: 122 | index % 2 === 0 && index !== filteredCoffees.length - 1 ? 16 : 0, 123 | }} 124 | /> 125 | )} 126 | /> 127 | 128 | 129 | ); 130 | } 131 | 132 | const styles = StyleSheet.create({ 133 | container: { 134 | flex: 1, 135 | backgroundColor: '#201520', 136 | }, 137 | 138 | headerContainer: { 139 | marginTop: STATUSBAR_HEIGHT, 140 | paddingHorizontal: 16, 141 | width: '100%', 142 | }, 143 | 144 | main: { 145 | flex: 1, 146 | marginTop: 20, 147 | alignItems: 'center', 148 | }, 149 | }); 150 | 151 | const coffeeListStyle = StyleSheet.create({ 152 | list: { 153 | marginLeft: 38, 154 | }, 155 | 156 | contentContainer: { 157 | paddingBottom: 16, 158 | }, 159 | }); 160 | -------------------------------------------------------------------------------- /src/pages/cart/index.tsx: -------------------------------------------------------------------------------- 1 | import { EvilIcons } from '@expo/vector-icons'; 2 | import { useFocusEffect, useNavigation } from '@react-navigation/native'; 3 | import { useCallback, useMemo } from 'react'; 4 | import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; 5 | 6 | import { ButtonText } from '~/components/button-text'; 7 | import { Divider } from '~/components/divider'; 8 | import { Row } from '~/components/row'; 9 | import { Ticket } from '~/components/ticket'; 10 | 11 | import { CartCard } from '~/pages/cart/components/card'; 12 | 13 | import { useCart } from '~/store/cart/cart-store'; 14 | 15 | import { formatCurrency } from '~/utils/format-currency'; 16 | 17 | export function Cart() { 18 | const items = useCart((state) => state.items); 19 | const amount = useCart((state) => state.amount); 20 | const clearCart = useCart((state) => state.clear); 21 | const setShowDot = useCart((state) => state.setShowDot); 22 | 23 | const amountWithDelivery = useMemo(() => { 24 | return amount + 6; 25 | }, [amount]); 26 | 27 | const navigation = useNavigation(); 28 | 29 | useFocusEffect( 30 | useCallback(() => { 31 | setShowDot(false); 32 | }, [setShowDot]) 33 | ); 34 | 35 | function handlePayButton() { 36 | Alert.alert('Payment', `You will pay ${formatCurrency(amountWithDelivery)}`, [ 37 | { text: 'Cancel', style: 'destructive' }, 38 | { 39 | text: 'Pay', 40 | onPress: () => { 41 | clearCart(); 42 | navigation.reset({ routes: [{ name: 'TabRoutes' }] }); 43 | }, 44 | }, 45 | ]); 46 | } 47 | 48 | return ( 49 | 50 | 51 | 55 | {items.length === 0 ? ( 56 | Cart is empty... 57 | ) : ( 58 | items.map((item, index) => ( 59 | 64 | )) 65 | )} 66 | 67 | 68 | 69 | 70 | Apply Coupon Code 71 | 72 | 73 | 74 | 75 | Delivery Charges 76 | {amount === 0 ? '$0.00' : '$6.00'} 77 | 78 | 79 | 80 | Taxes 81 | {formatCurrency(amount)} 82 | 83 | 84 | 85 | 86 | 87 | Grand Total 88 | 89 | {formatCurrency(amount === 0 ? 0 : amountWithDelivery)} 90 | 91 | 92 | 93 | 94 | 101 | 102 | 103 | ); 104 | } 105 | 106 | const styles = StyleSheet.create({ 107 | container: { 108 | flex: 1, 109 | }, 110 | 111 | main: { 112 | flex: 1, 113 | marginTop: 20, 114 | paddingHorizontal: 16, 115 | paddingBottom: 36, 116 | }, 117 | 118 | scrollViewContentStyle: { 119 | paddingBottom: 16, 120 | }, 121 | 122 | emptyText: { 123 | fontSize: 16, 124 | textAlign: 'center', 125 | color: '#EFE3C8', 126 | fontFamily: 'Rosarivo_400Regular', 127 | }, 128 | 129 | title: { 130 | fontSize: 24, 131 | color: '#fff', 132 | textAlign: 'center', 133 | fontFamily: 'Rosarivo_400Regular', 134 | }, 135 | 136 | ticketText: { 137 | fontSize: 14, 138 | color: '#EFE3C8', 139 | fontFamily: 'Rosarivo_400Regular', 140 | }, 141 | 142 | itemText: { 143 | fontSize: 14, 144 | color: '#fff', 145 | fontFamily: 'Rosarivo_400Regular', 146 | }, 147 | 148 | itemAmountText: { 149 | fontSize: 14, 150 | color: '#fff', 151 | fontFamily: 'OpenSans_600SemiBold', 152 | }, 153 | 154 | totalText: { 155 | fontSize: 20, 156 | color: '#fff', 157 | fontFamily: 'Rosarivo_400Regular', 158 | }, 159 | 160 | totalAmountText: { 161 | fontSize: 20, 162 | color: '#fff', 163 | fontFamily: 'OpenSans_600SemiBold', 164 | }, 165 | 166 | payButton: { 167 | marginTop: 'auto', 168 | height: 45, 169 | }, 170 | 171 | payButtonDisabled: {}, 172 | 173 | payButtonText: { 174 | fontSize: 16, 175 | fontFamily: 'OpenSans_600SemiBold', 176 | }, 177 | }); 178 | -------------------------------------------------------------------------------- /src/pages/description/index.tsx: -------------------------------------------------------------------------------- 1 | import { AntDesign, FontAwesome } from '@expo/vector-icons'; 2 | import { useNavigation, useRoute } from '@react-navigation/native'; 3 | import { useCallback, useState } from 'react'; 4 | import { Image, ScrollView, StyleSheet, Text, View } from 'react-native'; 5 | import { BorderlessButton } from 'react-native-gesture-handler'; 6 | 7 | import { STATUSBAR_HEIGHT } from '~/common/statusbar-height'; 8 | 9 | import { ButtonText } from '~/components/button-text'; 10 | import { GoBackButton } from '~/components/go-back-button'; 11 | import { Row } from '~/components/row'; 12 | 13 | import { DescriptionScreenRouteProp } from '~/routes/routes.type'; 14 | 15 | import { useCart } from '~/store/cart/cart-store'; 16 | import { useFavorites } from '~/store/favorites/favorites-store'; 17 | 18 | export function Description() { 19 | const [selectedMilk, setSelectedMilk] = useState(''); 20 | const [showFullDescription, setShowFullDescription] = useState(false); 21 | 22 | const { params } = useRoute(); 23 | const navigation = useNavigation(); 24 | const addItemToCart = useCart((state) => state.add); 25 | 26 | const isFavorite = useFavorites((state) => state.isFavorite(params.coffee.id)); 27 | const addItemToFavorites = useFavorites((state) => state.add); 28 | const removeItemToFavorites = useFavorites((state) => state.remove); 29 | 30 | const handlePayButton = useCallback(() => { 31 | addItemToCart(params.coffee); 32 | navigation.navigate('TabRoutes', { screen: 'Cart' }); 33 | }, [addItemToCart, navigation, params.coffee]); 34 | 35 | return ( 36 | 37 | 41 | 42 | 43 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {params.coffee.name} 56 | 58 | isFavorite 59 | ? removeItemToFavorites(params.coffee.id) 60 | : addItemToFavorites(params.coffee) 61 | } 62 | > 63 | {isFavorite ? ( 64 | 65 | ) : ( 66 | 67 | )} 68 | 69 | 70 | 71 | 72 | 73 | {params.coffee.simple_description} 74 | 75 | 76 | {params.coffee.stars} 77 | 78 | 79 | 83 | {params.coffee.description} 84 | 85 | 86 | setShowFullDescription((prevState) => !prevState)} 88 | style={styles.readMoreText} 89 | > 90 | {showFullDescription ? 'Read Less' : 'Read More'} 91 | 92 | 93 | 94 | Choice of Milk 95 | 96 | 101 | {params.coffee.milk_options.map((milk, index) => ( 102 | setSelectedMilk(milk)} 104 | key={index} 105 | text={milk} 106 | outline={selectedMilk !== milk} 107 | style={{ marginRight: 8 }} 108 | /> 109 | ))} 110 | 111 | 112 | 113 | 114 | 115 | Price 116 | {params.coffee.formatted_price} 117 | 118 | 119 | 125 | 126 | 127 | 128 | ); 129 | } 130 | 131 | const styles = StyleSheet.create({ 132 | container: { 133 | flex: 1, 134 | backgroundColor: '#201520', 135 | paddingTop: STATUSBAR_HEIGHT, 136 | }, 137 | 138 | scrollViewContainer: { paddingBottom: 24, flexGrow: 1 }, 139 | 140 | main: { 141 | flex: 1, 142 | marginTop: 16, 143 | 144 | paddingHorizontal: 16, 145 | }, 146 | 147 | imageWrapper: { 148 | paddingHorizontal: 16, 149 | }, 150 | 151 | goBackButton: { 152 | position: 'absolute', 153 | left: 10, 154 | top: 24, 155 | }, 156 | 157 | image: { 158 | height: 411, 159 | width: '100%', 160 | borderRadius: 40, 161 | }, 162 | 163 | nameText: { 164 | fontSize: 24, 165 | color: '#fff', 166 | fontFamily: 'Rosarivo_400Regular', 167 | }, 168 | 169 | shortDescriptionText: { 170 | fontSize: 16, 171 | color: '#fff', 172 | fontFamily: 'Rosarivo_400Regular', 173 | marginRight: 20, 174 | }, 175 | 176 | starText: { 177 | fontSize: 12, 178 | color: '#fff', 179 | lineHeight: 12, 180 | fontFamily: 'Rosarivo_400Regular', 181 | marginLeft: 5, 182 | }, 183 | 184 | descriptionText: { 185 | fontSize: 14, 186 | marginTop: 8, 187 | color: '#fff', 188 | fontFamily: 'OpenSans_400Regular', 189 | }, 190 | 191 | readMoreText: { 192 | fontSize: 14, 193 | marginTop: 8, 194 | color: '#fff', 195 | fontFamily: 'OpenSans_700Bold', 196 | textDecorationLine: 'underline', 197 | marginBottom: 30, 198 | }, 199 | 200 | milkChoiceText: { 201 | fontSize: 14, 202 | color: '#fff', 203 | marginTop: 'auto', 204 | fontFamily: 'Rosarivo_400Regular', 205 | marginLeft: 16, 206 | }, 207 | 208 | checkoutContainer: { 209 | marginTop: 45, 210 | justifyContent: 'space-between', 211 | paddingHorizontal: 16, 212 | }, 213 | 214 | priceLabel: { 215 | fontSize: 14, 216 | color: '#fff', 217 | fontFamily: 'OpenSans_400Regular', 218 | }, 219 | 220 | amountText: { 221 | fontSize: 24, 222 | color: '#fff', 223 | fontFamily: 'OpenSans_600SemiBold', 224 | }, 225 | 226 | buyButtonText: { 227 | fontSize: 16, 228 | color: '#4A2B29', 229 | fontFamily: 'OpenSans_600SemiBold', 230 | }, 231 | }); 232 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "coffees": [ 3 | { 4 | "id": "1", 5 | "name": "Espresso", 6 | "simple_description": "Also known as a short black", 7 | "description": "An espresso is a single shot of coffee. No extra hot water is added, resulting in an intense and flavoursome drink.\n\nAn espresso shot, which forms the basis of many of the other drinks to follow, is produced by forcing hot water through finely ground coffee beans.", 8 | "price": 2.00, 9 | "stars": 4.4, 10 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461044/coffee%20delivery%20app/jeremy-yap-jn-HaGWe4yw-unsplash_hqc04t.jpg", 11 | "categoryId": "4", 12 | "milk_options": ["No Milk"] 13 | }, 14 | { 15 | "id": "2", 16 | "name": "Doppio", 17 | "simple_description": "Also known as a double Espresso", 18 | "description": "A Doppio is a double shot of espresso with no added hot water or milk. This results in 60 ml of coffee. Doppio means 'double' in Italian.", 19 | "price": 4.00, 20 | "stars": 2.2, 21 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461307/coffee%20delivery%20app/double-espresso_sskxca.jpg", 22 | "categoryId": "4", 23 | "milk_options": ["No Milk"] 24 | }, 25 | { 26 | "id": "3", 27 | "name": "Ristretto", 28 | "simple_description": "Means 'restricted' in Italian", 29 | "description": "A shot of espresso coffee but extracted with half the amount of water. This results in a delicious, concentrated shot. A ristretto is typically enjoyed straight but it can also be combined with milk.", 30 | "price": 2.50, 31 | "stars": 2.3, 32 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461433/coffee%20delivery%20app/ristretto_blfnev.jpg", 33 | "categoryId": "4", 34 | "milk_options": ["No Milk", "Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 35 | }, 36 | { 37 | "id": "4", 38 | "name": "Long Black", 39 | "simple_description": "More is better", 40 | "description": "A long black is made by pouring a double-shot of espresso over hot water. Unlike an Americano which is made by adding hot water to the espresso shot, a long black retains the crema and is less voluminous, therefore more strongly flavoured.", 41 | "price": 3.80, 42 | "stars": 2.6, 43 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461492/coffee%20delivery%20app/ede9652f559e4948808c058843c97574_cdupde.jpg", 44 | "categoryId": "4", 45 | "milk_options": ["No Milk"] 46 | }, 47 | { 48 | "id": "5", 49 | "name": "Short Macchiato", 50 | "simple_description": "A shot of Espresso with milk", 51 | "description": "A short macchiato is served in a small glass consisting of a single espresso shot then filled with creamy steamed milk and finished with a small layer of foam.", 52 | "price": 4.00, 53 | "stars": 3.2, 54 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461519/coffee%20delivery%20app/Long-Macchiato-2-scaled_l24nal.jpg", 55 | "categoryId": "4", 56 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 57 | }, 58 | { 59 | "id": "6", 60 | "name": "Long Macchiato", 61 | "simple_description": "A double Espresso with milk", 62 | "description": "A long macchiato is similar to a short macchiato, except that it contains a double shot (around 60ml) of coffee.", 63 | "price": 6.00, 64 | "stars": 1.9, 65 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461571/coffee%20delivery%20app/long-short-macchiato_jkrbor.jpg", 66 | "categoryId": "4", 67 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 68 | }, 69 | { 70 | "id": "7", 71 | "name": "Mezzo Mezzo", 72 | "simple_description": "also known as a Piccolo", 73 | "description": "A mezzo mezzo is a single espresso shot in a small latte glass, which is then filled with steamed milk.", 74 | "price": 7.00, 75 | "stars": 4.3, 76 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461615/coffee%20delivery%20app/0517a8e29d3d7a3ff4bdfde7e090ab6f--italian-pronunciation-perth_1024x1024_hnz7kc.jpg", 77 | "categoryId": "4", 78 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 79 | }, 80 | { 81 | "id": "8", 82 | "name": "Cappuccino", 83 | "simple_description": "A shot of Espresso, but better", 84 | "description": "Recognized by the froth on top, a cappuccino is one part espresso shot, one part textured milk and one part froth on top with a dusting of chocolate to finish.\n\nAt Merlo locations, a cappuccino in a 8oz cup contains one shot of espresso, in a 12oz cup 1.5 shots and in a 16oz cup two shots. This is the same for a latte, flat white and mocha.", 85 | "price": 6.00, 86 | "stars": 3.0, 87 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461669/coffee%20delivery%20app/cappuccino-2029-e80b7c6d318c7862df2c4c8623a11f99_1x_uycqyi.jpg", 88 | "categoryId": "1", 89 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 90 | }, 91 | { 92 | "id": "9", 93 | "name": "Latte", 94 | "simple_description": "A lover coffee", 95 | "description": "A latte is a coffee espresso shot filled with steamed milk and with a layer of foamed milk crema.", 96 | "price": 6.00, 97 | "stars": 3.4, 98 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461732/coffee%20delivery%20app/how-to-make-a-latte-at-home_vaaqn9.jpg", 99 | "categoryId": "2", 100 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 101 | }, 102 | { 103 | "id": "10", 104 | "name": "Flat White", 105 | "simple_description": "Latte with a layer of foam", 106 | "description": "A flat white is very similar to a latte, with un-textured milk (no air incorporated when being steamed) resulting in espresso and steamed milk with little or no froth.", 107 | "price": 3.50, 108 | "stars": 1.8, 109 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649462667/coffee%20delivery%20app/fedee22e49724cd09fbcc7ee2e567377_zij2ww.jpg", 110 | "categoryId": "5", 111 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 112 | }, 113 | { 114 | "id": "11", 115 | "name": "Mocha", 116 | "simple_description": "Latte with chocolate", 117 | "description": "A latte with the added sweetness of chocolate. A mocha can be prepared by adding chocolate to the espresso shot before adding the textured milk, or adding the chocolate to the cold milk before frothing.", 118 | "price": 7.00, 119 | "stars": 3.1, 120 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461766/coffee%20delivery%20app/mocha-latte-recipe_lzkhqx.jpg", 121 | "categoryId": "2", 122 | "milk_options": ["Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 123 | }, 124 | { 125 | "id": "12", 126 | "name": "Affogato", 127 | "simple_description": "Coffee with ice cream", 128 | "description": "Combine a scoop of vanilla ice cream and a double shot of hot espresso and you have an affogato. Great for a post-lunch or dinner treat.", 129 | "price": 6.25, 130 | "stars": 3.9, 131 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461806/coffee%20delivery%20app/k_2FPhoto_2FRecipes_2F2020-07-How-to-make-affogato-at-home_2FKitchn_HowTo_Affogato_0281_pyghsl.jpg", 132 | "categoryId": "4", 133 | "milk_options": ["No Milk"] 134 | }, 135 | { 136 | "id": "13", 137 | "name": "Batch Brew", 138 | "simple_description": "Fast machine coffee", 139 | "description": "A batch brew is a coffee made in an automated pour over machine, like a Moccamaster. These machines use a filter and brew much larger quantities of coffee than devices like the AeroPress, V60 or Chemex.", 140 | "price": 4.00, 141 | "stars": 2.4, 142 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461864/coffee%20delivery%20app/moccamaster_aus_19_3_2018_22_57_4_738-1024x1024_c5uww5.jpg", 143 | "categoryId": "6", 144 | "milk_options": ["No Milk", "Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 145 | }, 146 | { 147 | "id": "14", 148 | "name": "Cold Drip", 149 | "simple_description": "No so fast machine coffee", 150 | "description": "Cold drip coffee is similar to coffee brewed in a pour-over device, except that the process uses cold water and takes much longer. Our Merlo stores use a Yama cold drip tower to make cold drip coffee.", 151 | "price": 3.80, 152 | "stars": 3.25, 153 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649462006/coffee%20delivery%20app/Delter-slow-drip_oaub67.jpg", 154 | "categoryId": "6", 155 | "milk_options": ["No Milk", "Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 156 | }, 157 | { 158 | "id": "15", 159 | "name": "Cold Brew", 160 | "simple_description": "Similar to a double-shot Espresso", 161 | "description": "Cold Brew coffee can is produced by submerging our premium Zambia coffee beans in cold water for an extended period of time. This results in a smooth and refreshing brew with 180mg of caffeine.", 162 | "price": 3.80, 163 | "stars": 2.7, 164 | "image_url": "https://res.cloudinary.com/eliasgcf/image/upload/v1649461936/coffee%20delivery%20app/cold-brew-tower-black_2_1024x_bgfzqf.jpg", 165 | "categoryId": "6", 166 | "milk_options": ["No Milk", "Oat Milk", "Soy Milk", "Almond Milk", "Coconut Milk", "Cream"] 167 | } 168 | ], 169 | "categories": [ 170 | { "id": "1", "name": "Cappuccino" }, 171 | { "id": "2", "name": "Latte" }, 172 | { "id": "3", "name": "Americano" }, 173 | { "id": "4", "name": "Espresso" }, 174 | { "id": "5", "name": "Flat White" }, 175 | { "id": "6", "name": "Others" } 176 | ] 177 | } 178 | --------------------------------------------------------------------------------