├── .gitignore ├── src ├── components │ ├── ReactGoogleReviews │ │ ├── index.ts │ │ └── ReactGoogleReviews.tsx │ ├── index.ts │ ├── StarRating │ │ ├── StarIcon.tsx │ │ └── StarRating.tsx │ ├── State │ │ ├── ErrorState.tsx │ │ └── LoadingState.tsx │ ├── Google │ │ ├── GoogleIcon.tsx │ │ └── GoogleLogo.tsx │ ├── Badge │ │ └── Badge.tsx │ ├── Carousel │ │ └── Carousel.tsx │ └── ReviewCard │ │ └── ReviewCard.tsx ├── index.ts ├── utils │ ├── displayName.ts │ ├── getRelativeDate.ts │ ├── trim.ts │ └── apiTransformers.ts ├── app │ └── index.tsx ├── types │ ├── review.ts │ └── cssProps.ts ├── static │ └── examples.ts ├── lib │ └── fetchPlaceReviews.ts └── css │ └── index.css ├── public ├── images │ ├── badge-example.png │ ├── carousel-example.png │ ├── featurable-widget-id.png │ ├── react-google-reviews.jpg │ └── featurable-icon.svg └── index.html ├── postcss.config.js ├── tailwind.config.js ├── example ├── index.html └── index.tsx ├── .prettierrc.json ├── tsconfig.build.json ├── tsconfig.json ├── webpack.config.js ├── LICENSE ├── rollup.config.dev.js ├── rollup.config.js ├── CONTRIBUTING.md ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .idea 5 | .vscode 6 | -------------------------------------------------------------------------------- /src/components/ReactGoogleReviews/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ReactGoogleReviews"; 2 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ReactGoogleReviews } from "./ReactGoogleReviews"; 2 | -------------------------------------------------------------------------------- /public/images/badge-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/featurable/react-google-reviews/HEAD/public/images/badge-example.png -------------------------------------------------------------------------------- /public/images/carousel-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/featurable/react-google-reviews/HEAD/public/images/carousel-example.png -------------------------------------------------------------------------------- /public/images/featurable-widget-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/featurable/react-google-reviews/HEAD/public/images/featurable-widget-id.png -------------------------------------------------------------------------------- /public/images/react-google-reviews.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/featurable/react-google-reviews/HEAD/public/images/react-google-reviews.jpg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/**/*.tsx" 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Google Reviews Example 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "arrowParens": "always", 11 | "endOfLine": "lf", 12 | "proseWrap": "preserve" 13 | } 14 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Google Reviews 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactGoogleReviews } from "./components"; 2 | import "./css/index.css"; 3 | import { dangerouslyFetchPlaceReviews } from "./lib/fetchPlaceReviews"; 4 | import { FeaturableAPIResponse, GoogleReview } from "./types/review"; 5 | 6 | export { 7 | dangerouslyFetchPlaceReviews, 8 | FeaturableAPIResponse, 9 | GoogleReview as ReactGoogleReview, 10 | ReactGoogleReviews, 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/displayName.ts: -------------------------------------------------------------------------------- 1 | import { NameDisplay } from "../types/review"; 2 | 3 | export const displayName = ( 4 | name: string, 5 | config: NameDisplay 6 | ): string => { 7 | const split = name.split(" "); 8 | if (split.length === 1) { 9 | return name; 10 | } 11 | 12 | switch (config) { 13 | case "firstNamesOnly": 14 | return split[0]; 15 | case "fullNames": 16 | return name; 17 | default: 18 | case "firstAndLastInitials": 19 | return `${split[0]} ${split[ 20 | split.length - 1 21 | ][0].toUpperCase()}.`; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { ReactGoogleReviews } from "../src"; 4 | 5 | const App: React.FC<{}> = () => { 6 | return ( 7 |
8 |

React Google Reviews Example

9 | 13 | 17 |
18 | ); 19 | }; 20 | 21 | const root = createRoot(document.getElementById("root")!); 22 | root.render(); 23 | -------------------------------------------------------------------------------- /src/utils/getRelativeDate.ts: -------------------------------------------------------------------------------- 1 | export const getRelativeDate = (date: Date): string => { 2 | const today = new Date(); 3 | const diffTime = Math.abs(today.getTime() - date.getTime()); 4 | const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 5 | const diffMonths = Math.floor(diffDays / 30); 6 | const diffYears = Math.floor(diffMonths / 12); 7 | 8 | if (diffYears > 0) { 9 | return `${diffYears} year${diffYears > 1 ? "s" : ""} ago`; 10 | } else if (diffMonths > 0) { 11 | return `${diffMonths} month${diffMonths > 1 ? "s" : ""} ago`; 12 | } else { 13 | return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "skipLibCheck": true, 9 | "module": "ESNext", 10 | "declaration": true, 11 | "declarationDir": "types", 12 | "sourceMap": true, 13 | "outDir": "dist", 14 | "moduleResolution": "node", 15 | "allowSyntheticDefaultImports": true, 16 | "emitDeclarationOnly": true, 17 | "jsx": "react-jsx", 18 | "jsxImportSource": "@emotion/react", 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "example"] 22 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "lib": ["dom", "dom.iterable", "esnext"], 8 | "skipLibCheck": true, 9 | "module": "ESNext", 10 | "declaration": true, 11 | "declarationDir": "types", 12 | "sourceMap": true, 13 | "outDir": "dist", 14 | "moduleResolution": "node", 15 | "allowSyntheticDefaultImports": true, 16 | "emitDeclarationOnly": false, 17 | "jsx": "react-jsx", 18 | "jsxImportSource": "@emotion/react", 19 | }, 20 | "include": ["src", "example"], 21 | "exclude": ["node_modules"] 22 | } -------------------------------------------------------------------------------- /src/utils/trim.ts: -------------------------------------------------------------------------------- 1 | export const trim = (str: string, maxLength: number): string => { 2 | if (str.length <= maxLength) { 3 | return str; 4 | } 5 | 6 | let trimmedString = str.slice(0, maxLength); 7 | 8 | // Trim to the nearest word 9 | const lastSpaceIndex = trimmedString.lastIndexOf(" "); 10 | if (lastSpaceIndex !== -1) { 11 | trimmedString = trimmedString.slice(0, lastSpaceIndex); 12 | } 13 | 14 | // Remove trailing non-alphanumeric characters 15 | trimmedString = trimmedString.replace(/[^a-zA-Z0-9]+$/, ""); 16 | 17 | // Add ellipsis if the trimmed string is shorter than the original 18 | if (trimmedString.length < str.length) { 19 | trimmedString += "..."; 20 | } 21 | 22 | return trimmedString; 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { ReactGoogleReviews } from '../components'; 3 | 4 | const container = document.getElementById('root')!; 5 | const root = createRoot(container); 6 | 7 | root.render( 8 |
9 |

react-google-reviews

10 | 11 |

Carousel v1:

12 | 13 | 14 |

Badge v1:

15 | 16 | 17 |

Carousel v2:

18 | 19 | 20 |

Badge v2:

21 | 22 |
23 | ); 24 | -------------------------------------------------------------------------------- /src/components/StarRating/StarIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const StarIcon: FC<{ 4 | className?: string; 5 | ref?: any; 6 | }> = ({ className, ref }) => { 7 | return ( 8 | 15 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: './example/index.tsx', 7 | output: { 8 | path: path.resolve(__dirname, 'example/dist'), 9 | filename: 'bundle.js', 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.tsx?$/, 15 | use: 'ts-loader', 16 | exclude: /node_modules/, 17 | }, 18 | { 19 | test: /\.css$/, 20 | use: ['style-loader', 'css-loader'], 21 | }, 22 | ], 23 | }, 24 | resolve: { 25 | extensions: ['.tsx', '.ts', '.js'], 26 | }, 27 | plugins: [ 28 | new HtmlWebpackPlugin({ 29 | template: './example/index.html', 30 | }), 31 | ], 32 | devServer: { 33 | static: { 34 | directory: path.join(__dirname, 'example/dist'), 35 | }, 36 | compress: true, 37 | port: 3000, 38 | open: true, 39 | }, 40 | }; -------------------------------------------------------------------------------- /src/components/StarRating/StarRating.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import { FC } from "react"; 5 | import { StarIcon } from "./StarIcon"; 6 | 7 | const starRating = css` 8 | display: flex; 9 | align-items: center; 10 | `; 11 | 12 | const star = css` 13 | height: 20px; 14 | width: 20px; 15 | `; 16 | 17 | const starFilled = css` 18 | color: #f8af0d; 19 | `; 20 | 21 | const starEmpty = css` 22 | color: #6b7280; 23 | `; 24 | 25 | export const StarRating: FC<{ 26 | rating: number; 27 | className?: string; 28 | }> = ({ rating }) => { 29 | return ( 30 |
31 | {Array.from({ length: 5 }).map((_, i) => ( 32 | = i + 1 ? starFilled : starEmpty, 37 | ]} 38 | /> 39 | ))} 40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/State/ErrorState.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import React, { FC } from "react"; 5 | import { ErrorStateCSSProps } from "../../types/cssProps"; 6 | 7 | const error = css` 8 | padding-top: 64px; 9 | padding-bottom: 64px; 10 | text-align: center; 11 | font-size: 16px; 12 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 13 | Helvetica, Arial, sans-serif, "Apple Color Emoji", 14 | "Segoe UI Emoji", "Segoe UI Symbol"; 15 | `; 16 | 17 | type ErrorStateProps = { 18 | errorMessage?: React.ReactNode; 19 | }; 20 | 21 | export const ErrorState: FC = ({ 22 | errorMessage, 23 | errorClassName, 24 | errorStyle, 25 | }) => { 26 | return ( 27 |
32 | {errorMessage ?? 33 | "Failed to load Google reviews. Please try again later."} 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Featurable.com 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. -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import replace from '@rollup/plugin-replace'; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import livereload from 'rollup-plugin-livereload'; 6 | import postcss from "rollup-plugin-postcss"; 7 | import serve from 'rollup-plugin-serve'; 8 | 9 | export default { 10 | input: "src/app/index.tsx", 11 | output: { 12 | file: "dist/dev/bundle.js", 13 | format: "esm", 14 | sourcemap: true, 15 | }, 16 | plugins: [ 17 | replace({ 18 | 'process.env.NODE_ENV': JSON.stringify('development'), 19 | preventAssignment: true 20 | }), 21 | resolve({ 22 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 23 | }), 24 | commonjs({ 25 | include: /node_modules/ 26 | }), 27 | typescript({ 28 | tsconfig: "./tsconfig.build.json", 29 | }), 30 | postcss({ 31 | extract: true, 32 | minimize: false, 33 | }), 34 | serve({ 35 | contentBase: ['dist', 'public'], 36 | port: 3000, 37 | open: true 38 | }), 39 | livereload('dist'), 40 | ], 41 | } -------------------------------------------------------------------------------- /public/images/featurable-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/Google/GoogleIcon.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import React from "react"; 5 | 6 | const googleIcon = css` 7 | height: 32px; 8 | width: 32px; 9 | `; 10 | 11 | export const GoogleIcon: React.FC<{ 12 | className?: string; 13 | style?: React.CSSProperties; 14 | }> = ({ className, style }) => { 15 | return ( 16 | 23 | 27 | 31 | 35 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import terser from "@rollup/plugin-terser"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | import analyze from 'rollup-plugin-analyzer'; 6 | import dts from "rollup-plugin-dts"; 7 | import peerDepsExternal from "rollup-plugin-peer-deps-external"; 8 | import postcss from "rollup-plugin-postcss"; 9 | 10 | const packageJson = require("./package.json"); 11 | 12 | export default [ 13 | { 14 | input: "src/index.ts", 15 | output: [ 16 | { 17 | file: packageJson.main, 18 | format: "cjs", 19 | sourcemap: true, 20 | }, 21 | { 22 | file: packageJson.module, 23 | format: "esm", 24 | sourcemap: true, 25 | }, 26 | ], 27 | plugins: [ 28 | peerDepsExternal(), 29 | resolve({ 30 | ignoreGlobal: false, 31 | include: ['node_modules/**'], 32 | skip: ['react', 'react-dom'], 33 | }), 34 | commonjs(), 35 | typescript({ 36 | tsconfig: "./tsconfig.build.json", 37 | }), 38 | postcss({ 39 | extract: true, 40 | minimize: true, 41 | }), 42 | terser(), 43 | analyze({ 44 | summaryOnly: true, 45 | }) 46 | ], 47 | }, 48 | { 49 | input: "dist/esm/types/index.d.ts", 50 | output: [{ file: "dist/index.d.ts", format: "esm" }], 51 | plugins: [dts.default()], 52 | external: [/\.css$/], 53 | }, 54 | { 55 | input: "src/css/index.css", 56 | output: [{ file: "dist/index.css", format: "es" }], 57 | plugins: [ 58 | postcss({ 59 | extract: true, 60 | minimize: true, 61 | }), 62 | ], 63 | }, 64 | ]; 65 | -------------------------------------------------------------------------------- /src/utils/apiTransformers.ts: -------------------------------------------------------------------------------- 1 | import { GoogleReview, GoogleReviewV2, FeaturableAPIResponseV1, FeaturableAPIResponseV2 } from '../types/review'; 2 | 3 | /** 4 | * Transforms a v2 GoogleReview into v1 format for backwards compatibility 5 | */ 6 | export function transformV2ReviewToV1(reviewV2: GoogleReviewV2): GoogleReview { 7 | return { 8 | reviewId: reviewV2.id, 9 | reviewer: { 10 | profilePhotoUrl: reviewV2.author?.photoUrl ?? '', 11 | displayName: reviewV2.author?.name ?? 'Anonymous', 12 | isAnonymous: !reviewV2.author?.name, 13 | }, 14 | starRating: reviewV2.rating ? reviewV2.rating.value : 0, 15 | comment: reviewV2.text, 16 | createTime: reviewV2.createdAt, 17 | updateTime: reviewV2.updatedAt ?? null, 18 | }; 19 | } 20 | 21 | /** 22 | * Transforms a v2 API response into v1 format for backwards compatibility 23 | */ 24 | export function transformV2ResponseToV1(responseV2: FeaturableAPIResponseV2): FeaturableAPIResponseV1 { 25 | if (!responseV2.success) { 26 | return { success: false }; 27 | } 28 | 29 | return { 30 | success: true, 31 | profileUrl: responseV2.widget.gbpLocationSummary.writeAReviewUri, 32 | totalReviewCount: responseV2.widget.gbpLocationSummary.reviewsCount, 33 | averageRating: responseV2.widget.gbpLocationSummary.rating, 34 | reviews: responseV2.widget.reviews.map(transformV2ReviewToV1), 35 | }; 36 | } 37 | 38 | /** 39 | * Type guard to check if response is v2 40 | */ 41 | export function isV2Response( 42 | response: FeaturableAPIResponseV1 | FeaturableAPIResponseV2 43 | ): response is FeaturableAPIResponseV2 { 44 | if (!response.success) return false; 45 | 46 | return 'widget' in response; 47 | } 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Thank you for your interest in contributing to React Google Reviews! 4 | 5 | We appreciate all contributions, whether it's fixing a bug, adding a feature, improving documentation, or optimizing performance. This guide will help you get started. 6 | 7 | --- 8 | 9 | ## Project Setup 10 | 11 | ### 1. Fork & Clone 12 | First, **fork** the repository and **clone** it locally: 13 | 14 | ```sh 15 | git clone https://github.com/Featurable/react-google-reviews.git 16 | cd react-google-reviews 17 | ``` 18 | 19 | ### 2. Install Dependencies 20 | Ensure you have Node.js (LTS recommended) and install dependencies: 21 | 22 | ```sh 23 | npm install 24 | ``` 25 | 26 | ### 3. Start Development Environment 27 | 28 | Start the development environment by running: 29 | 30 | ```sh 31 | npm run dev 32 | ``` 33 | 34 | ## How to Contribute 35 | 36 | ### 1. Find an Issue 37 | 38 | - Check [GitHub Issues](https://github.com/Featurable/react-google-reviews/issues) 39 | - If you have an idea, create a new issue before submitting a PR. 40 | 41 | ### 2. Create a New Branch 42 | 43 | Follow branch naming conventions: 44 | 45 | ```sh 46 | git checkout -b feature/my-new-component # For new features 47 | git checkout -b fix/button-padding # For bug fixes 48 | ``` 49 | 50 | ### 3. Write Code & Tests 51 | 52 | - Follow our coding style (Prettier). 53 | - If applicable, update documentation (`README.md`). 54 | 55 | ### 4. Commit & Push 56 | 57 | Commit messages should be descriptive: 58 | 59 | ```sh 60 | git commit -m "fix(button): resolve padding issue" 61 | git push origin feature/my-new-component 62 | ``` 63 | 64 | ### 5. Submit a PR 65 | - Open a **Pull Request** against the `main` branch. 66 | - Reference the issue it fixes (if applicable). 67 | - Request a review from a maintainer. 68 | 69 | --- 70 | 71 | ## Community & Support 72 | 73 | We appreciate your contributions—thank you for making React Google Reviews better! -------------------------------------------------------------------------------- /src/components/Google/GoogleLogo.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import React from "react"; 5 | 6 | const googleLogo = css` 7 | height: 32px; 8 | `; 9 | 10 | export const GoogleLogo: React.FC<{ 11 | className?: string; 12 | }> = ({ className }) => { 13 | return ( 14 | 20 | 24 | 28 | 32 | 33 | 37 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-google-reviews", 3 | "version": "1.8.0", 4 | "description": "A React component to easily display Google reviews using Google Places API or Google My Business API.", 5 | "scripts": { 6 | "start": "webpack serve --config webpack.config.js", 7 | "dev": "rollup -c rollup.config.dev.js -w --bundleConfigAsCjs", 8 | "build": "rollup -c --bundleConfigAsCjs" 9 | }, 10 | "keywords": [ 11 | "react", 12 | "google", 13 | "reviews", 14 | "google reviews", 15 | "google places", 16 | "google my business", 17 | "google maps" 18 | ], 19 | "author": "Ryan Chiang (https://featurable.com)", 20 | "homepage": "https://github.com/ryanschiang/react-google-reviews#readme", 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/ryanschiang/react-google-reviews.git" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/ryanschiang/react-google-reviews/issues", 28 | "email": "ryan@featurable.com" 29 | }, 30 | "devDependencies": { 31 | "@rollup/plugin-commonjs": "^26.0.1", 32 | "@rollup/plugin-html": "^1.0.3", 33 | "@rollup/plugin-node-resolve": "^15.2.3", 34 | "@rollup/plugin-replace": "^5.0.7", 35 | "@rollup/plugin-terser": "^0.4.4", 36 | "@rollup/plugin-typescript": "^11.1.6", 37 | "@types/react": "^18.3.3", 38 | "@types/react-dom": "^18.3.0", 39 | "@types/react-slick": "^0.23.13", 40 | "autoprefixer": "^10.4.19", 41 | "css-loader": "^7.1.2", 42 | "html-webpack-plugin": "^5.6.0", 43 | "postcss": "^8.4.38", 44 | "postcss-import": "^16.1.0", 45 | "react": "^18.3.1", 46 | "react-dom": "^18.3.1", 47 | "rollup": "^4.18.0", 48 | "rollup-plugin-analyzer": "^4.0.0", 49 | "rollup-plugin-dts": "^6.1.1", 50 | "rollup-plugin-livereload": "^2.0.5", 51 | "rollup-plugin-peer-deps-external": "^2.2.4", 52 | "rollup-plugin-postcss": "^4.0.2", 53 | "rollup-plugin-serve": "^1.1.1", 54 | "style-loader": "^4.0.0", 55 | "tailwindcss": "^3.4.4", 56 | "ts-loader": "^9.5.1", 57 | "tslib": "^2.6.3", 58 | "typescript": "^5.5.4", 59 | "webpack": "^5.93.0", 60 | "webpack-cli": "^5.1.4", 61 | "webpack-dev-server": "^5.0.4" 62 | }, 63 | "main": "dist/cjs/index.js", 64 | "module": "dist/esm/index.js", 65 | "files": [ 66 | "dist" 67 | ], 68 | "types": "dist/index.d.ts", 69 | "peerDependencies": { 70 | "@types/react": "^18.0.0 || ^19.0.0-rc", 71 | "react": "^18.0.0 || ^19.0.0-rc", 72 | "react-dom": "^18.0.0 || ^19.0.0-rc" 73 | }, 74 | "dependencies": { 75 | "@emotion/react": "^11.13.0", 76 | "clsx": "^2.1.1", 77 | "react-slick": "^0.30.2", 78 | "slick-carousel": "^1.8.1" 79 | }, 80 | "engines": { 81 | "node": ">= 10.13" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/State/LoadingState.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css, keyframes } from "@emotion/react"; 4 | import React, { FC } from "react"; 5 | import { LoadingStateCSSProps } from "../../types/cssProps"; 6 | 7 | const loader = css` 8 | width: 100%; 9 | padding-top: 80px; 10 | padding-bottom: 80px; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 16 | Helvetica, Arial, sans-serif, "Apple Color Emoji", 17 | "Segoe UI Emoji", "Segoe UI Symbol"; 18 | `; 19 | 20 | const spin = keyframes` 21 | to { 22 | transform: rotate(360deg); 23 | } 24 | `; 25 | 26 | const loaderSpinner = css` 27 | height: 40px; 28 | width: 40px; 29 | color: #e5e7eb; 30 | fill: #2563eb; 31 | animation: ${spin} 1s linear infinite; 32 | `; 33 | 34 | const loaderLabel = css` 35 | margin-top: 8px; 36 | font-size: 16px; 37 | font-weight: 600; 38 | `; 39 | 40 | type LoadingStateProps = { 41 | loadingMessage?: React.ReactNode; 42 | }; 43 | 44 | export const LoadingState: FC< 45 | LoadingStateProps & LoadingStateCSSProps 46 | > = ({ 47 | loadingMessage, 48 | loaderClassName, 49 | loaderStyle, 50 | loaderSpinnerClassName, 51 | loaderSpinnerStyle, 52 | loaderLabelClassName, 53 | loaderLabelStyle, 54 | }) => { 55 | return ( 56 |
61 | 81 |

86 | {loadingMessage ?? "Loading reviews..."} 87 |

88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/types/review.ts: -------------------------------------------------------------------------------- 1 | export type GoogleReview = { 2 | reviewId: string | null; 3 | reviewer: { 4 | profilePhotoUrl: string; 5 | displayName: string; 6 | isAnonymous: boolean; 7 | }; 8 | starRating: number; 9 | comment: string; 10 | createTime: string | null; 11 | updateTime: string | null; 12 | }; 13 | 14 | // Force type into value space to avoid type-only export incompatibility 15 | export const GoogleReview = {}; 16 | 17 | export type GoogleReviewV2 = { 18 | id: string; 19 | platform: string; 20 | 21 | author?: { 22 | name?: string | null; 23 | avatarUrl?: string | null; 24 | photoUrl?: string | null; 25 | } | null; 26 | 27 | title?: string | null; 28 | text: string; 29 | originalText?: string | null; 30 | languageCode?: string | null; 31 | 32 | rating?: { 33 | value: number; 34 | max: number; 35 | } | null; 36 | 37 | publishedAt: string; 38 | updatedAt?: string | null; 39 | createdAt: string; 40 | lastSyncedAt?: string | null; 41 | 42 | metadata?: Record | null; 43 | url?: string | null; 44 | }; 45 | 46 | // Force type into value space to avoid type-only export incompatibility 47 | export const GoogleReviewV2 = {}; 48 | 49 | export type NameDisplay = 'fullNames' | 'firstAndLastInitials' | 'firstNamesOnly'; 50 | 51 | export type LogoVariant = 'icon' | 'full' | 'none'; 52 | 53 | export type DateDisplay = 'relative' | 'absolute' | 'none'; 54 | 55 | export type ReviewVariant = 'testimonial' | 'card'; 56 | 57 | export type Theme = 'light' | 'dark'; 58 | 59 | interface FeaturableAPIResponseV1Base { 60 | success: boolean; 61 | } 62 | 63 | interface FeaturableAPIResponseV1Success extends FeaturableAPIResponseV1Base { 64 | success: true; 65 | profileUrl: string | null; 66 | totalReviewCount: number; 67 | averageRating: number; 68 | reviews: GoogleReview[]; 69 | } 70 | 71 | interface FeaturableAPIResponseV1Error extends FeaturableAPIResponseV1Base { 72 | success: false; 73 | } 74 | 75 | export type FeaturableAPIResponseV1 = FeaturableAPIResponseV1Success | FeaturableAPIResponseV1Error; 76 | 77 | // Force type into value space to avoid type-only export incompatibility 78 | export const FeaturableAPIResponseV1 = {} as const; 79 | 80 | interface FeaturableAPIResponseV2Base { 81 | success: boolean; 82 | } 83 | 84 | interface FeaturableAPIResponseV2Success extends FeaturableAPIResponseV2Base { 85 | success: true; 86 | widget: { 87 | gbpLocationSummary: { 88 | reviewsCount: number; 89 | rating: number; 90 | writeAReviewUri: string; 91 | }; 92 | reviews: GoogleReviewV2[]; 93 | }; 94 | } 95 | 96 | interface FeaturableAPIResponseV2Error extends FeaturableAPIResponseV2Base { 97 | success: false; 98 | } 99 | 100 | export type FeaturableAPIResponseV2 = FeaturableAPIResponseV2Success | FeaturableAPIResponseV2Error; 101 | 102 | // Force type into value space to avoid type-only export incompatibility 103 | export const FeaturableAPIResponseV2 = {} as const; 104 | 105 | export type FeaturableAPIResponse = FeaturableAPIResponseV1 | FeaturableAPIResponseV2; 106 | 107 | // Force type into value space to avoid type-only export incompatibility 108 | export const FeaturableAPIResponse = {} as const; 109 | 110 | // Widget version type 111 | export type WidgetVersion = 'v1' | 'v2'; 112 | -------------------------------------------------------------------------------- /src/static/examples.ts: -------------------------------------------------------------------------------- 1 | import { ReactGoogleReview } from ".."; 2 | 3 | export const EXAMPLE_REVIEWS: ReactGoogleReview[] = [ 4 | { 5 | reviewId: "1", 6 | reviewer: { 7 | displayName: "Isabella Harris", 8 | profilePhotoUrl: "", 9 | isAnonymous: false, 10 | }, 11 | comment: 12 | "I was hesitant to invest in this product at first, but I'm so glad I did. It has been a total game-changer for me and has made a significant positive impact on my work. It's worth every penny.", 13 | starRating: 5, 14 | createTime: new Date().toISOString(), 15 | updateTime: new Date().toISOString(), 16 | }, 17 | { 18 | reviewId: "2", 19 | reviewer: { 20 | displayName: "Sophia Moore", 21 | profilePhotoUrl: "", 22 | isAnonymous: false, 23 | }, 24 | comment: 25 | "I've tried similar products in the past, but none of them compare to this one. It's in a league of its own in terms of functionality, durability, and overall value. I can't recommend it highly enough!", 26 | starRating: 5, 27 | createTime: new Date().toISOString(), 28 | updateTime: new Date().toISOString(), 29 | }, 30 | { 31 | reviewId: "3", 32 | reviewer: { 33 | displayName: "John Doe", 34 | profilePhotoUrl: "", 35 | isAnonymous: false, 36 | }, 37 | comment: 38 | "This product is a game-changer! I've been using it for a few months now and it has consistently delivered excellent results. It's easy to use, well-designed, and built to last.", 39 | starRating: 5, 40 | createTime: new Date().toISOString(), 41 | updateTime: new Date().toISOString(), 42 | }, 43 | { 44 | reviewId: "4", 45 | reviewer: { 46 | displayName: "Emily Davis", 47 | profilePhotoUrl: "", 48 | isAnonymous: false, 49 | }, 50 | comment: 51 | "I've been using this product for a few weeks now and I'm blown away by how well it works. It's intuitive, easy to use, and has already saved me a ton of time. I can't imagine going back to the way I used to do things before I had this product. I've been using it for a few months now and it has consistently delivered excellent results. It's easy to use, well-designed, and built to last.", 52 | starRating: 5, 53 | createTime: new Date().toISOString(), 54 | updateTime: new Date().toISOString(), 55 | }, 56 | { 57 | reviewId: "5", 58 | reviewer: { 59 | displayName: "David Wilson", 60 | profilePhotoUrl: "", 61 | isAnonymous: false, 62 | }, 63 | comment: 64 | "I was skeptical at first, but after using this product for a while, I'm a believer. It's well-designed, durable, and does exactly what it's supposed to do. I couldn't be happier with my purchase.", 65 | starRating: 5, 66 | createTime: new Date().toISOString(), 67 | updateTime: new Date().toISOString(), 68 | }, 69 | { 70 | reviewId: "6", 71 | reviewer: { 72 | displayName: "Jessica Brown", 73 | profilePhotoUrl: "", 74 | isAnonymous: false, 75 | }, 76 | comment: 77 | "I love this product! It has exceeded my expectations in every way. Setup was a breeze and it works flawlessly. I've already recommended it to several friends and family members.", 78 | starRating: 5, 79 | createTime: new Date().toISOString(), 80 | updateTime: new Date().toISOString(), 81 | }, 82 | ]; 83 | -------------------------------------------------------------------------------- /src/lib/fetchPlaceReviews.ts: -------------------------------------------------------------------------------- 1 | import { GoogleReview } from "../types/review"; 2 | 3 | class FetchPlaceReviewsError extends Error { 4 | constructor(message: string, public code?: string) { 5 | super(message); 6 | this.name = "FetchPlaceReviewsError"; 7 | } 8 | } 9 | 10 | interface FetchPlaceReviewsBaseResponse { 11 | success: boolean; 12 | } 13 | 14 | interface FetchPlaceReviewsSuccessResponse 15 | extends FetchPlaceReviewsBaseResponse { 16 | success: true; 17 | reviews: GoogleReview[]; 18 | } 19 | 20 | interface FetchPlaceReviewsErrorResponse 21 | extends FetchPlaceReviewsBaseResponse { 22 | success: false; 23 | error: FetchPlaceReviewsError; 24 | } 25 | 26 | type FetchPlaceReviewsResponse = 27 | | FetchPlaceReviewsSuccessResponse 28 | | FetchPlaceReviewsErrorResponse; 29 | 30 | /** 31 | * IMPORTANT: ONLY CALL THIS FUNCTION SERVER-SIDE TO AVOID EXPOSING YOUR API KEY TO THE CLIENT 32 | * 33 | * This function will fetch the reviews of a place using the Google Places API 34 | * and return them as an array of GoogleReview objects to pass to `ReactGoogleReviews` component. 35 | * 36 | * Create a Google API key and enable the Places API in the [Google Cloud Console](https://console.cloud.google.com). 37 | * You can find your Place ID using the [Place ID Finder Tool](https://developers.google.com/maps/documentation/javascript/examples/places-placeid-finder). 38 | * For businesses without a physical address, see our [docs](https://featurable.com/docs/google-reviews/faq#how-to-get-google-reviews-for-a-business-without-a-physical-address). 39 | */ 40 | export const dangerouslyFetchPlaceReviews = async ( 41 | placeId: string, 42 | apiKey: string 43 | ): Promise => { 44 | if (typeof window !== "undefined") { 45 | console.warn( 46 | "dangerouslyFetchPlaceReviews should only be called server-side to avoid exposing your API key." 47 | ); 48 | return { 49 | success: false, 50 | error: new FetchPlaceReviewsError( 51 | "dangerouslyFetchPlaceReviews should only be called server-side to avoid exposing your API key." 52 | ), 53 | }; 54 | } 55 | 56 | const baseUrl = `https://maps.googleapis.com/maps/api/place/details/json`; 57 | const params = { 58 | place_id: placeId, 59 | fields: "reviews", 60 | key: apiKey, 61 | } as Record; 62 | const queryString = Object.keys(params) 63 | .map( 64 | (key) => 65 | `${encodeURIComponent(key)}=${encodeURIComponent( 66 | params[key] 67 | )}` 68 | ) 69 | .join("&"); 70 | const url = `${baseUrl}?${queryString}`; 71 | 72 | try { 73 | const res = await fetch(url, { 74 | method: "GET", 75 | }); 76 | 77 | if (!res.ok) { 78 | const errorResponse = await res.json(); 79 | throw new FetchPlaceReviewsError( 80 | errorResponse.error_message || 81 | "Unknown error occurred", 82 | errorResponse.status 83 | ); 84 | } 85 | 86 | const data = await res.json(); 87 | 88 | if (data.status !== "OK") { 89 | throw new FetchPlaceReviewsError( 90 | data.error_message || "Unknown error occurred", 91 | data.status 92 | ); 93 | } 94 | 95 | const reviews: GoogleReview[] = ( 96 | data.result.reviews || [] 97 | ).map((rawReview: any) => { 98 | const review: GoogleReview = { 99 | reviewId: rawReview.review_id || null, 100 | reviewer: { 101 | isAnonymous: !rawReview.author_name, 102 | displayName: rawReview.author_name || "Anonymous", 103 | profilePhotoUrl: 104 | rawReview.profile_photo_url || "", 105 | }, 106 | starRating: rawReview.rating || 0, 107 | createTime: rawReview.time 108 | ? new Date(rawReview.time * 1000).toISOString() 109 | : null, 110 | updateTime: rawReview.time 111 | ? new Date(rawReview.time * 1000).toISOString() 112 | : null, 113 | comment: rawReview.text || "", 114 | }; 115 | 116 | return review; 117 | }); 118 | 119 | return { 120 | success: true, 121 | reviews, 122 | }; 123 | } catch (err) { 124 | if (err instanceof FetchPlaceReviewsError) { 125 | console.error( 126 | `Error fetching place reviews: ${err.message} (status: ${err.code})` 127 | ); 128 | return { 129 | success: false, 130 | error: err, 131 | }; 132 | } else { 133 | console.error(`Unexpected error occurred:`, err); 134 | return { 135 | success: false, 136 | error: new FetchPlaceReviewsError( 137 | "Unexpected error occurred", 138 | "UNKNOWN" 139 | ), 140 | }; 141 | } 142 | } 143 | }; 144 | -------------------------------------------------------------------------------- /src/types/cssProps.ts: -------------------------------------------------------------------------------- 1 | export type ReviewCardCSSProps = { 2 | reviewCardClassName?: string; 3 | reviewCardStyle?: React.CSSProperties; 4 | 5 | reviewCardLightClassName?: string; 6 | reviewCardLightStyle?: React.CSSProperties; 7 | 8 | reviewCardDarkClassName?: string; 9 | reviewCardDarkStyle?: React.CSSProperties; 10 | 11 | reviewBodyCardClassName?: string; 12 | reviewBodyCardStyle?: React.CSSProperties; 13 | 14 | reviewBodyTestimonialClassName?: string; 15 | reviewBodyTestimonialStyle?: React.CSSProperties; 16 | 17 | reviewTextClassName?: string; 18 | reviewTextStyle?: React.CSSProperties; 19 | 20 | reviewTextLightClassName?: string; 21 | reviewTextLightStyle?: React.CSSProperties; 22 | 23 | reviewTextDarkClassName?: string; 24 | reviewTextDarkStyle?: React.CSSProperties; 25 | 26 | reviewReadMoreClassName?: string; 27 | reviewReadMoreStyle?: React.CSSProperties; 28 | 29 | reviewReadMoreLightClassName?: string; 30 | reviewReadMoreLightStyle?: React.CSSProperties; 31 | 32 | reviewReadMoreDarkClassName?: string; 33 | reviewReadMoreDarkStyle?: React.CSSProperties; 34 | 35 | reviewFooterClassName?: string; 36 | reviewFooterStyle?: React.CSSProperties; 37 | 38 | reviewerClassName?: string; 39 | reviewerStyle?: React.CSSProperties; 40 | 41 | reviewerProfileClassName?: string; 42 | reviewerProfileStyle?: React.CSSProperties; 43 | 44 | reviewerProfileImageClassName?: string; 45 | reviewerProfileImageStyle?: React.CSSProperties; 46 | 47 | reviewerProfileFallbackClassName?: string; 48 | reviewerProfileFallbackStyle?: React.CSSProperties; 49 | 50 | reviewerNameClassName?: string; 51 | reviewerNameStyle?: React.CSSProperties; 52 | 53 | reviewerNameLightClassName?: string; 54 | reviewerNameLightStyle?: React.CSSProperties; 55 | 56 | reviewerNameDarkClassName?: string; 57 | reviewerNameDarkStyle?: React.CSSProperties; 58 | 59 | reviewerDateClassName?: string; 60 | reviewerDateStyle?: React.CSSProperties; 61 | 62 | reviewerDateLightClassName?: string; 63 | reviewerDateLightStyle?: React.CSSProperties; 64 | 65 | reviewerDateDarkClassName?: string; 66 | reviewerDateDarkStyle?: React.CSSProperties; 67 | }; 68 | 69 | export type BadgeCSSProps = { 70 | badgeClassName?: string; 71 | badgeStyle?: React.CSSProperties; 72 | 73 | badgeContainerClassName?: string; 74 | badgeContainerStyle?: React.CSSProperties; 75 | 76 | badgeContainerLightClassName?: string; 77 | badgeContainerLightStyle?: React.CSSProperties; 78 | 79 | badgeContainerDarkClassName?: string; 80 | badgeContainerDarkStyle?: React.CSSProperties; 81 | 82 | badgeGoogleIconClassName?: string; 83 | badgeGoogleIconStyle?: React.CSSProperties; 84 | 85 | badgeInnerContainerClassName?: string; 86 | badgeInnerContainerStyle?: React.CSSProperties; 87 | 88 | badgeLabelClassName?: string; 89 | badgeLabelStyle?: React.CSSProperties; 90 | 91 | badgeLabelLightClassName?: string; 92 | badgeLabelLightStyle?: React.CSSProperties; 93 | 94 | badgeLabelDarkClassName?: string; 95 | badgeLabelDarkStyle?: React.CSSProperties; 96 | 97 | badgeRatingContainerClassName?: string; 98 | badgeRatingContainerStyle?: React.CSSProperties; 99 | 100 | badgeRatingClassName?: string; 101 | badgeRatingStyle?: React.CSSProperties; 102 | 103 | badgeRatingLightClassName?: string; 104 | badgeRatingLightStyle?: React.CSSProperties; 105 | 106 | badgeRatingDarkClassName?: string; 107 | badgeRatingDarkStyle?: React.CSSProperties; 108 | 109 | badgeStarsClassName?: string; 110 | badgeStarsStyle?: React.CSSProperties; 111 | 112 | badgeStarsContainerClassName?: string; 113 | badgeStarsContainerStyle?: React.CSSProperties; 114 | 115 | badgeStarsFilledClassName?: string; 116 | badgeStarsFilledStyle?: React.CSSProperties; 117 | 118 | badgeStarsEmptyClassName?: string; 119 | badgeStarsEmptyStyle?: React.CSSProperties; 120 | 121 | badgeLinkContainerClassName?: string; 122 | badgeLinkContainerStyle?: React.CSSProperties; 123 | 124 | badgeLinkClassName?: string; 125 | badgeLinkStyle?: React.CSSProperties; 126 | 127 | badgeLinkLightClassName?: string; 128 | badgeLinkLightStyle?: React.CSSProperties; 129 | 130 | badgeLinkDarkClassName?: string; 131 | badgeLinkDarkStyle?: React.CSSProperties; 132 | 133 | badgeLinkInlineClassName?: string; 134 | badgeLinkInlineStyle?: React.CSSProperties; 135 | }; 136 | 137 | export type CarouselCSSProps = { 138 | carouselClassName?: string; 139 | carouselStyle?: React.CSSProperties; 140 | 141 | carouselBtnClassName?: string; 142 | carouselBtnStyle?: React.CSSProperties; 143 | 144 | carouselBtnLeftClassName?: string; 145 | carouselBtnLeftStyle?: React.CSSProperties; 146 | 147 | carouselBtnRightClassName?: string; 148 | carouselBtnRightStyle?: React.CSSProperties; 149 | 150 | carouselBtnLightClassName?: string; 151 | carouselBtnLightStyle?: React.CSSProperties; 152 | 153 | carouselBtnDarkClassName?: string; 154 | carouselBtnDarkStyle?: React.CSSProperties; 155 | 156 | carouselBtnIconClassName?: string; 157 | carouselBtnIconStyle?: React.CSSProperties; 158 | 159 | carouselCardClassName?: string; 160 | carouselCardStyle?: React.CSSProperties; 161 | }; 162 | 163 | export type ErrorStateCSSProps = { 164 | errorClassName?: string; 165 | errorStyle?: React.CSSProperties; 166 | }; 167 | 168 | export type LoadingStateCSSProps = { 169 | loaderClassName?: string; 170 | loaderStyle?: React.CSSProperties; 171 | 172 | loaderSpinnerClassName?: string; 173 | loaderSpinnerStyle?: React.CSSProperties; 174 | 175 | loaderLabelClassName?: string; 176 | loaderLabelStyle?: React.CSSProperties; 177 | }; 178 | -------------------------------------------------------------------------------- /src/css/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | The CSS code below is taken from the `react-slick` project, 3 | which is licensed under the MIT License. 4 | Source: https://github.com/akiran/react-slick 5 | 6 | The MIT License (MIT) 7 | 8 | Copyright (c) 2014 Kiran Abburi 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. */ 27 | 28 | .slick-dots > li > button::before { 29 | font-size: 45px !important; 30 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important; 31 | } 32 | .slick-dots > li.slick-active > button::before { 33 | font-size: 45px !important; 34 | color: hsl(0, 0%, 20%); 35 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !important; 36 | } 37 | 38 | .slick-track { 39 | display: flex; 40 | align-items: start; 41 | } 42 | 43 | .slick-loading .slick-list 44 | { 45 | background: #fff; 46 | } 47 | 48 | .slick-prev, 49 | .slick-next 50 | { 51 | font-size: 0; 52 | line-height: 0; 53 | 54 | position: absolute; 55 | top: 50%; 56 | 57 | display: block; 58 | 59 | width: 20px; 60 | height: 20px; 61 | padding: 0; 62 | -webkit-transform: translate(0, -50%); 63 | -ms-transform: translate(0, -50%); 64 | transform: translate(0, -50%); 65 | 66 | cursor: pointer; 67 | 68 | color: transparent; 69 | border: none; 70 | outline: none; 71 | background: transparent; 72 | } 73 | .slick-prev:hover, 74 | .slick-prev:focus, 75 | .slick-next:hover, 76 | .slick-next:focus 77 | { 78 | color: transparent; 79 | outline: none; 80 | background: transparent; 81 | } 82 | .slick-prev:hover:before, 83 | .slick-prev:focus:before, 84 | .slick-next:hover:before, 85 | .slick-next:focus:before 86 | { 87 | opacity: 1; 88 | } 89 | .slick-prev.slick-disabled:before, 90 | .slick-next.slick-disabled:before 91 | { 92 | opacity: .25; 93 | } 94 | 95 | .slick-prev:before, 96 | .slick-next:before 97 | { 98 | font-size: 20px; 99 | line-height: 1; 100 | 101 | opacity: .75; 102 | color: white; 103 | 104 | -webkit-font-smoothing: antialiased; 105 | -moz-osx-font-smoothing: grayscale; 106 | } 107 | 108 | .slick-prev 109 | { 110 | left: -25px; 111 | } 112 | [dir='rtl'] .slick-prev 113 | { 114 | right: -25px; 115 | left: auto; 116 | } 117 | .slick-prev:before 118 | { 119 | content: '←'; 120 | } 121 | [dir='rtl'] .slick-prev:before 122 | { 123 | content: '→'; 124 | } 125 | 126 | .slick-next 127 | { 128 | right: -25px; 129 | } 130 | [dir='rtl'] .slick-next 131 | { 132 | right: auto; 133 | left: -25px; 134 | } 135 | .slick-next:before 136 | { 137 | content: '→'; 138 | } 139 | [dir='rtl'] .slick-next:before 140 | { 141 | content: '←'; 142 | } 143 | 144 | /* Dots */ 145 | .slick-dotted.slick-slider 146 | { 147 | margin-bottom: 30px; 148 | } 149 | 150 | .slick-dots 151 | { 152 | position: absolute; 153 | bottom: -25px; 154 | 155 | display: block; 156 | 157 | width: 100%; 158 | padding: 0; 159 | margin: 0; 160 | 161 | list-style: none; 162 | 163 | text-align: center; 164 | } 165 | .slick-dots li 166 | { 167 | position: relative; 168 | 169 | display: inline-block; 170 | 171 | width: 20px; 172 | height: 20px; 173 | margin: 0 5px; 174 | padding: 0; 175 | 176 | cursor: pointer; 177 | } 178 | .slick-dots li button 179 | { 180 | font-size: 0; 181 | line-height: 0; 182 | 183 | display: block; 184 | 185 | width: 20px; 186 | height: 20px; 187 | padding: 5px; 188 | 189 | cursor: pointer; 190 | 191 | color: transparent; 192 | border: 0; 193 | outline: none; 194 | background: transparent; 195 | } 196 | .slick-dots li button:hover, 197 | .slick-dots li button:focus 198 | { 199 | outline: none; 200 | } 201 | .slick-dots li button:hover:before, 202 | .slick-dots li button:focus:before 203 | { 204 | opacity: 1; 205 | } 206 | .slick-dots li button:before 207 | { 208 | font-size: 6px; 209 | line-height: 20px; 210 | 211 | position: absolute; 212 | top: 0; 213 | left: 0; 214 | 215 | width: 20px; 216 | height: 20px; 217 | 218 | content: '•'; 219 | text-align: center; 220 | 221 | opacity: .25; 222 | color: black; 223 | 224 | -webkit-font-smoothing: antialiased; 225 | -moz-osx-font-smoothing: grayscale; 226 | } 227 | .slick-dots li.slick-active button:before 228 | { 229 | opacity: .75; 230 | color: black; 231 | } 232 | 233 | /* Slider */ 234 | .slick-slider 235 | { 236 | position: relative; 237 | 238 | display: block; 239 | box-sizing: border-box; 240 | 241 | -webkit-user-select: none; 242 | -moz-user-select: none; 243 | -ms-user-select: none; 244 | user-select: none; 245 | 246 | -webkit-touch-callout: none; 247 | -khtml-user-select: none; 248 | -ms-touch-action: pan-y; 249 | touch-action: pan-y; 250 | -webkit-tap-highlight-color: transparent; 251 | } 252 | 253 | .slick-list 254 | { 255 | position: relative; 256 | 257 | display: block; 258 | overflow: hidden; 259 | 260 | margin: 0; 261 | padding: 0; 262 | } 263 | .slick-list:focus 264 | { 265 | outline: none; 266 | } 267 | .slick-list.dragging 268 | { 269 | cursor: pointer; 270 | cursor: hand; 271 | } 272 | 273 | .slick-slider .slick-track, 274 | .slick-slider .slick-list 275 | { 276 | -webkit-transform: translate3d(0, 0, 0); 277 | -moz-transform: translate3d(0, 0, 0); 278 | -ms-transform: translate3d(0, 0, 0); 279 | -o-transform: translate3d(0, 0, 0); 280 | transform: translate3d(0, 0, 0); 281 | } 282 | 283 | .slick-track 284 | { 285 | position: relative; 286 | top: 0; 287 | left: 0; 288 | 289 | display: block; 290 | margin-left: auto; 291 | margin-right: auto; 292 | } 293 | .slick-track:before, 294 | .slick-track:after 295 | { 296 | display: table; 297 | 298 | content: ''; 299 | } 300 | .slick-track:after 301 | { 302 | clear: both; 303 | } 304 | .slick-loading .slick-track 305 | { 306 | visibility: hidden; 307 | } 308 | 309 | .slick-slide 310 | { 311 | display: none; 312 | float: left; 313 | 314 | height: 100%; 315 | min-height: 1px; 316 | } 317 | [dir='rtl'] .slick-slide 318 | { 319 | float: right; 320 | } 321 | .slick-slide img 322 | { 323 | display: block; 324 | } 325 | .slick-slide.slick-loading img 326 | { 327 | display: none; 328 | } 329 | .slick-slide.dragging img 330 | { 331 | pointer-events: none; 332 | } 333 | .slick-initialized .slick-slide 334 | { 335 | display: block; 336 | } 337 | .slick-loading .slick-slide 338 | { 339 | visibility: hidden; 340 | } 341 | .slick-vertical .slick-slide 342 | { 343 | display: block; 344 | 345 | height: auto; 346 | 347 | border: 1px solid transparent; 348 | } 349 | .slick-arrow.slick-hidden { 350 | display: none; 351 | } -------------------------------------------------------------------------------- /src/components/Badge/Badge.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import clsx from "clsx"; 5 | import { FC, useMemo } from "react"; 6 | import { BadgeCSSProps } from "../../types/cssProps"; 7 | import { Theme } from "../../types/review"; 8 | import { GoogleIcon } from "../Google/GoogleIcon"; 9 | 10 | const badge = css` 11 | text-align: center; 12 | width: 100%; 13 | `; 14 | 15 | const badgeContainer = css` 16 | text-align: left; 17 | display: inline-flex; 18 | align-items: center; 19 | border-top: 4px solid #10b981; 20 | border-radius: 6px; 21 | padding: 12px 16px; 22 | margin: 0 auto; 23 | box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 24 | 0 1px 2px -1px rgb(0 0 0 / 0.1); 25 | `; 26 | 27 | const badgeContainerLight = css` 28 | background: white; 29 | `; 30 | 31 | const badgeContainerDark = css` 32 | background: #111827; 33 | `; 34 | 35 | const badgeGoogleIcon = css` 36 | height: 42px; 37 | width: 42px; 38 | `; 39 | 40 | const badgeInnerContainer = css` 41 | padding-left: 1rem; 42 | line-height: normal; 43 | `; 44 | 45 | const badgeLabel = css` 46 | font-size: 1rem; 47 | font-weight: 500; 48 | line-height: 1; 49 | `; 50 | 51 | const badgeLabelLight = css` 52 | color: #111827; 53 | `; 54 | 55 | const badgeLabelDark = css` 56 | color: #f9fafb; 57 | `; 58 | 59 | const badgeRatingContainer = css` 60 | display: flex; 61 | align-items: center; 62 | margin-top: 6px; 63 | `; 64 | 65 | const badgeRating = css` 66 | font-size: 16px; 67 | font-weight: 600; 68 | display: inline-block; 69 | `; 70 | 71 | const badgeRatingLight = css` 72 | color: #d97706; 73 | `; 74 | 75 | const badgeRatingDark = css` 76 | color: #f59e0b; 77 | `; 78 | 79 | const badgeStars = css` 80 | margin-left: 4px; 81 | `; 82 | 83 | const badgeStarsContainer = css` 84 | position: relative; 85 | font-size: 20px; 86 | line-height: 1; 87 | padding: 0; 88 | margin: 0; 89 | `; 90 | 91 | const badgeStarsFilled = css` 92 | display: flex; 93 | align-items: center; 94 | position: absolute; 95 | left: 0; 96 | top: 0; 97 | overflow: hidden; 98 | color: #f8af0d; 99 | `; 100 | 101 | const badgeStarsEmpty = css` 102 | display: flex; 103 | align-items: center; 104 | color: #d1d5db; 105 | `; 106 | 107 | const badgeLinkContainer = css` 108 | font-size: 12px; 109 | margin-top: 8px; 110 | `; 111 | 112 | const badgeLink = css` 113 | &:hover { 114 | text-decoration: underline; 115 | } 116 | `; 117 | 118 | const badgeLinkLight = css` 119 | color: #6b7280; 120 | `; 121 | 122 | const badgeLinkDark = css` 123 | color: #9ca3af; 124 | `; 125 | 126 | const badgeLinkInline = css` 127 | display: inline-block; 128 | `; 129 | 130 | type BadgeProps = { 131 | averageRating: number; 132 | totalReviewCount: number; 133 | profileUrl?: string | null; 134 | theme?: Theme; 135 | badgeLabel?: string; 136 | badgeSubheadingFormatter?: (totalReviewCount: number) => string; 137 | }; 138 | 139 | export const Badge: FC = ({ 140 | averageRating, 141 | totalReviewCount, 142 | profileUrl, 143 | theme = "light", 144 | badgeLabel = "Google Rating", 145 | badgeSubheadingFormatter = (totalReviewCount) => 146 | `Read our ${totalReviewCount} reviews`, 147 | badgeClassName, 148 | badgeStyle, 149 | badgeContainerClassName, 150 | badgeContainerStyle, 151 | badgeContainerLightClassName, 152 | badgeContainerLightStyle, 153 | badgeContainerDarkClassName, 154 | badgeContainerDarkStyle, 155 | badgeGoogleIconClassName, 156 | badgeGoogleIconStyle, 157 | badgeInnerContainerClassName, 158 | badgeInnerContainerStyle, 159 | badgeLabelClassName, 160 | badgeLabelStyle, 161 | badgeLabelLightClassName, 162 | badgeLabelLightStyle, 163 | badgeLabelDarkClassName, 164 | badgeLabelDarkStyle, 165 | badgeRatingContainerClassName, 166 | badgeRatingContainerStyle, 167 | badgeRatingClassName, 168 | badgeRatingStyle, 169 | badgeRatingLightClassName, 170 | badgeRatingLightStyle, 171 | badgeRatingDarkClassName, 172 | badgeRatingDarkStyle, 173 | badgeStarsClassName, 174 | badgeStarsStyle, 175 | badgeStarsContainerClassName, 176 | badgeStarsContainerStyle, 177 | badgeStarsFilledClassName, 178 | badgeStarsFilledStyle, 179 | badgeStarsEmptyClassName, 180 | badgeStarsEmptyStyle, 181 | badgeLinkContainerClassName, 182 | badgeLinkContainerStyle, 183 | badgeLinkClassName, 184 | badgeLinkStyle, 185 | badgeLinkLightClassName, 186 | badgeLinkLightStyle, 187 | badgeLinkDarkClassName, 188 | badgeLinkDarkStyle, 189 | badgeLinkInlineClassName, 190 | badgeLinkInlineStyle, 191 | }) => { 192 | const percentageFill = useMemo(() => { 193 | const pct = (averageRating / 5) * 100; 194 | return pct; 195 | }, [averageRating]); 196 | 197 | return ( 198 |
203 |
223 | 228 |
233 | 253 | {badgeLabel} 254 | 255 |
260 | 280 | {averageRating.toFixed(1)} 281 | 282 | 318 |
319 |
324 | {profileUrl ? ( 325 | 350 | {badgeSubheadingFormatter( 351 | totalReviewCount 352 | )} 353 | 354 | ) : ( 355 | 378 | {badgeSubheadingFormatter( 379 | totalReviewCount 380 | )} 381 | 382 | )} 383 |
384 |
385 |
386 |
387 | ); 388 | }; 389 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # React Google Reviews 4 | 5 | ![React Google Reviews Integration by Featurable](public/images/react-google-reviews.jpg) 6 | 7 |
8 | Making adding Google reviews to any React app beautiful, easy, and free!
9 |
10 | Report a Bug 11 | - 12 | Request a Feature 13 | - 14 | Ask a Question 15 |
16 | 17 |
18 | 19 |
20 | 21 | [![npm](https://img.shields.io/npm/v/react-google-reviews?style=flat-square)](https://www.npmjs.com/package/react-google-reviews) 22 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/Featurable/react-google-reviews?sort=semver&style=flat-square)](https://github.com/Featurable/react-google-reviews/releases) 23 | [![Release Date](https://img.shields.io/github/release-date/Featurable/react-google-reviews?style=flat-square)](https://github.com/Featurable/react-google-reviews/releases/latest) 24 | [![License: MIT](https://img.shields.io/badge/license-%20MIT-blue?style=flat-square&logo=gnu)](https://github.com/Featurable/react-google-reviews/blob/main/LICENSE) 25 | [![Pull Requests welcome](https://img.shields.io/badge/PRs-welcome-ff69b4.svg?style=flat-square)](https://github.com/Featurable/react-google-reviews/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%2C%22Status%3A+Available%22+sort%3Aupdated-desc+) 26 | 27 |
28 | 29 | --- 30 | 31 | 32 | 33 | Featurable 34 | 35 | This 36 | React Google Reviews library is brought to you by 37 | Featurable, and the following 38 | documentation can also be found at 39 | https://featurable.com/docs/react-google-reviews 40 | 41 | --- 42 | 43 | **What is it?** React component to display Google reviews on your website. This library is built with React and uses the Google Places API -or- the free Featurable API to fetch and display Google reviews on your website. 44 | 45 | Documentation and examples at [https://featurable.com/docs/react-google-reviews](https://featurable.com/docs/react-google-reviews). Source code at [https://github.com/Featurable/react-google-reviews](https://github.com/Featurable/react-google-reviews). 46 | 47 | ## Demo 48 | 49 | Check out the [live demo](https://featurable.com/docs/react-google-reviews#live-demo) to see the React Google Reviews library in action. 50 | 51 | ## Features 52 | 53 | 1. 🛠️ **Customizable**: Choose from three layout options and customize the appearance of the reviews component 54 | 2. 🔎 **SEO-friendly**: Include JSON-LD structured data for search engines to index your reviews 55 | 3. 💻 **Responsive**: Works on all devices and screen sizes 56 | 4. ⚡ **Fast**: Caches reviews for quick loading and improved performance 57 | 5. ✨ **Free**: No cost to use the Featurable API for fetching reviews 58 | 6. 🌱 **Fresh**: Automatically updates with new reviews from Google every 48 hours (using Featurable API) 59 | 7. ♿️ **Accessible**: Built with accessibility in mind (WAI-ARIA compliant) 60 | 8. 🪶 **Lightweight**: Small bundle size and minimal dependencies 61 | 62 | ## Installation 63 | 64 | Install it from npm, yarn, or pnpm: 65 | 66 | ```sh 67 | npm install react-google-reviews 68 | ``` 69 | 70 | ```sh 71 | yarn add react-google-reviews 72 | ``` 73 | 74 | ```sh 75 | pnpm add react-google-reviews 76 | ``` 77 | 78 | ## Usage 79 | 80 | The `` component renders Google reviews. You can supply Google reviews to the component automatically using the Featurable API or by manually fetching and passing reviews. 81 | 82 | ### Using the Featurable API (recommended) 83 | 84 | Prerequisites: 85 | 1. Create a free Featurable account at [https://featurable.com](https://featurable.com?ref=react-google-reviews) 86 | 2. Create a new Featurable widget 87 | 3. Click Embed > API and copy the widget ID 88 | 89 | > [!NOTE] 90 | > The Featurable API is free to use and provides additional features like caching, automatic updates, and more reviews. To prevent abuse, the Featurable API is subject to rate limits. 91 | 92 | ![Copy Featurable Widget ID](public/images/featurable-widget-id.png) 93 | 94 | ```jsx 95 | import { ReactGoogleReviews } from "react-google-reviews"; 96 | import "react-google-reviews/dist/index.css"; 97 | 98 | function Reviews() { 99 | // Create a free Featurable account at https://featurable.com 100 | // Then create a new Featurable widget and copy the widget ID 101 | const featurableWidgetId = "842ncdd8-0f40-438d-9c..."; // You can use "example" for testing 102 | 103 | return ( 104 | 105 | ); 106 | } 107 | ``` 108 | 109 | ### Using the Google Places API (limited to 5 reviews) 110 | 111 | Prerequisites: 112 | 1. Create a Google Cloud Platform account at [https://cloud.google.com](https://cloud.google.com) 113 | 2. Create a new project and enable the Google Places API **(old version)** 114 | 3. Find the Google Place ID using the [Place ID Finder](https://developers.google.com/maps/documentation/javascript/examples/places-placeid-finder) 115 | 116 | ```jsx 117 | import { ReactGoogleReviews, dangerouslyFetchPlaceReviews } from "react-google-reviews"; 118 | import "react-google-reviews/dist/index.css"; 119 | 120 | /** 121 | * Example using NextJS server component 122 | */ 123 | async function ReviewsPage() { 124 | const placeId = "ChIJN1t_tDeuEmsRU..."; // Google Place ID 125 | const apiKey = "AIzaSyD..."; // Google API Key 126 | 127 | // IMPORTANT: Only fetch reviews server-side to avoid exposing API key 128 | const reviews = await dangerouslyFetchPlaceReviews(placeId, apiKey) 129 | 130 | return ( 131 | // Carousel and other layouts require wrapping ReactGoogleReviews in a client component 132 | 133 | ); 134 | } 135 | 136 | export default ReviewsPage; 137 | ``` 138 | 139 | > [!NOTE] 140 | > The Google Places API **only returns the 5 most recent reviews.** If you need more reviews or want to customize which reviews are returned, consider using the [free Featurable API](https://featurable.com/). 141 | 142 | ## Configuration 143 | 144 | ### Layout 145 | 146 | There are three layout options currently available: 147 | 148 | 1. **Badge**: Display a badge with the average rating, total reviews, and link to Google Business profile 149 | 150 | ```jsx 151 | 152 | ``` 153 | 154 | Badge Layout 155 | 156 | 2. **Carousel**: An interactive carousel that displays reviews 157 | 158 | ```jsx 159 | 160 | ``` 161 | 162 | ![Carousel Layout](public/images/carousel-example.png) 163 | 164 | 3. **Custom renderer**: Render reviews using a custom function 165 | 166 | ```jsx 167 | { 168 | return ( 169 |
170 | {reviews.map(({ reviewId, reviewer, comment }) => ( 171 |
172 |

{reviewer.displayName}

173 |

{comment}

174 |
175 | ))} 176 |
177 | ); 178 | }} /> 179 | ``` 180 | 181 | The `reviews` prop is an array of `ReactGoogleReview` objects with the following structure: 182 | 183 | ``` 184 | { 185 | reviewId: string | null; 186 | reviewer: { 187 | profilePhotoUrl: string; 188 | displayName: string; 189 | isAnonymous: boolean; 190 | }; 191 | starRating: number; 192 | comment: string; 193 | createTime: string | null; 194 | updateTime: string | null; 195 | reviewReply?: { 196 | comment: string; 197 | updateTime: string; 198 | } | null; 199 | }; 200 | ``` 201 | 202 | ### CSS Styling 203 | 204 | For the carousel widget to work correctly, you must include the CSS file in your project: 205 | 206 | ```jsx 207 | import "react-google-reviews/dist/index.css"; 208 | ``` 209 | 210 | To override the default styles, you can use the CSS props to add custom styles: 211 | 212 | ```jsx 213 | 220 | 221 | 227 | ``` 228 | 229 | Please see the documentation for a list of CSS properties and examples of how to style the component. 230 | 231 | [View CSS classes and examples](https://featurable.com/docs/react-google-reviews#css-classes) 232 | 233 | ## Props 234 | 235 | ### Common Props 236 | 237 | | Prop | Type | Description | 238 | | --- | --- | --- | 239 | | featurableId | `string` | Featurable widget ID | 240 | | reviews | [GoogleReview](#googlereview)[] | Array of reviews to display, fetched using `dangerouslyFetchPlaceReviews` | 241 | | layout | `"badge" \| "carousel" \| "custom"` | Layout of the reviews component | 242 | | nameDisplay?| `"fullNames" \| "firstAndLastInitials" \| "firstNamesOnly"` | How to display names on reviews |; 243 | | logoVariant? | `"logo" \| "icon" \| "none"` | How to display the Google logo | 244 | | maxCharacters? | `number` | When collapsed, the maximum number of characters to display in the review body | 245 | | dateDisplay? | `"relative" \| "absolute"` | How to display the review date | 246 | | reviewVariant? | `"card" \| "testimonial"` | Review layout variations | 247 | | theme? | `"light" \| "dark"` | Color scheme of the component | 248 | | structuredData? | `boolean` | Whether to include JSON-LD structured data for SEO | 249 | | brandName? | `string` | Customize business name for structured data | 250 | | productName? | `string` | Customize product name for structured data | 251 | | productDescription? | `string` | Optional product description for structured data | 252 | | accessibility? | `boolean` | Enable/disable accessibility features | 253 | | hideEmptyReviews? | `boolean` | Hide reviews without text | 254 | | disableTranslation? | `boolean` | Disables translation from Google to use original review text | 255 | | totalReviewCount? | `number` | Total number of reviews on Google Business profile. This is automatically fetched if using `featurableId`. Otherwise, this is required if passing reviews manually and `structuredData` is true. | 256 | | averageRating? | `number` | Average rating for Google Business profile. This is automatically fetched if using `featurableId`. Otherwise, this is required if passing reviews manually and `structuredData` is true. | 257 | | errorMessage? | `React.ReactNode` | Custom error message to display if reviews cannot be fetched | 258 | | loadingMessage? | `React.ReactNode` | Custom loading message to display while reviews are loading | 259 | | isLoading? | `boolean` | Controls the loading state of the component when fetching reviews manually | 260 | 261 | #### `ReactGoogleReview` Model 262 | 263 | | Prop | Type | Description | 264 | | --- | --- | --- | 265 | | reviewId | `string \| null` | Unique review ID | 266 | | reviewer | `{ profilePhotoUrl: string; displayName: string; isAnonymous: boolean; }` | Reviewer information | 267 | | starRating | `number` | Star rating (1-5) | 268 | | comment | `string` | Review text | 269 | | createTime | `string \| null` | Review creation time | 270 | | updateTime | `string \| null` | Review update time | 271 | | reviewReply? | `{ comment: string; updateTime: string; } \| null` | Review reply information | 272 | 273 | 274 | ### Carousel Props 275 | 276 | | Prop | Type | Description | 277 | | --- | --- | --- | 278 | | carouselSpeed? | `number` | Autoplay speed of the carousel in milliseconds | 279 | | carouselAutoplay? | `boolean` | Whether to autoplay the carousel | 280 | | maxItems? | `number` | Maximum number of items to display at any one time in carousel | 281 | | readMoreLabel? | `string` | Read more label for truncated reviews. | 282 | | readLessLabel? | `string` | Read less label for expanded reviews. | 283 | | getRelativeDate? | `(date: Date) => string` | Formatting function for relative dates. | 284 | | getAbsoluteDate? | `(date: Date) => string` | Formatting function for absolute dates. | 285 | | showDots? | `boolean` | Whether to show/hide navigation dots in the carousel | 286 | 287 | ### Badge Props 288 | 289 | | Prop | Type | Description | 290 | | --- | --- | --- | 291 | | profileUrl? | `string` | Link to Google Business profile, if manually fetching reviews via Place API. Using Featurable API will automatically supply this URL. | 292 | | badgeLabel? | `string` | Label for the badge. | 293 | | badgeSubheadingFormatter? | `(totalReviewCount: number) => string` | Function to format the badge subheading. | 294 | 295 | ### Custom Layout Props 296 | 297 | | Prop | Type | Description | 298 | | --- | --- | --- | 299 | | renderer? | (reviews: [ReactGoogleReview](#reactgooglereview-model)[]) => React.ReactNode | Custom rendering function | 300 | 301 | ## License 302 | 303 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. By using the Featurable API, you agree to the [Featurable Terms of Service](https://featurable.com/terms). 304 | 305 | ## Acknowledgements 306 | 307 | This library uses [`slick-carousel`](https://github.com/kenwheeler/slick) and [`react-slick`](https://github.com/akiran/react-slick) under the MIT license for the carousel layout. You can find the respective licenses [here](https://github.com/kenwheeler/slick?tab=MIT-1-ov-file#readme) and [here](https://github.com/akiran/react-slick?tab=MIT-1-ov-file#readme), respectively. 308 | 309 | ## Contributing 310 | 311 | Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 312 | 313 | Please see the [Contributing Guidelines](https://github.com/Featurable/react-google-reviews/blob/main/CONTRIBUTING.md) for details on how to contribute. 314 | 315 | ## Issues 316 | 317 | Please report any issues or bugs you encounter on the [GitHub Issues](https://github.com/Featurable/react-google-reviews/issues) page. 318 | 319 | For support using Featurable, please contact us through your [Featurable dashboard](https://featurable.com/app). -------------------------------------------------------------------------------- /src/components/Carousel/Carousel.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import clsx from "clsx"; 5 | import React, { FC, useMemo } from "react"; 6 | import Slider from "react-slick"; 7 | import { 8 | CarouselCSSProps, 9 | ReviewCardCSSProps, 10 | } from "../../types/cssProps"; 11 | import { 12 | DateDisplay, 13 | GoogleReview, 14 | LogoVariant, 15 | NameDisplay, 16 | ReviewVariant, 17 | Theme, 18 | } from "../../types/review"; 19 | import { ReviewCard } from "../ReviewCard/ReviewCard"; 20 | 21 | const carousel = css` 22 | max-width: 1280px; 23 | margin: 0 auto; 24 | padding: 0 40px 32px; 25 | position: relative; 26 | `; 27 | 28 | const carouselBtn = css` 29 | position: absolute; 30 | top: 50%; 31 | transform: translateY(-50%); 32 | height: 40px; 33 | width: 40px; 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | border-radius: 100%; 38 | transition: all; 39 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 40 | cursor: pointer; 41 | 42 | &:hover { 43 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); 44 | } 45 | `; 46 | 47 | const carouselBtnLeft = css` 48 | left: 0; 49 | `; 50 | const carouselBtnRight = css` 51 | right: 0; 52 | `; 53 | const carouselBtnLight = css` 54 | background: white; 55 | color: hsl(0, 0%, 20%); 56 | border: 1px solid hsl(0, 0%, 80%); 57 | 58 | &:hover { 59 | background: hsl(0, 0%, 95%); 60 | border: 1px solid hsl(0, 0%, 80%); 61 | } 62 | `; 63 | const carouselBtnDark = css` 64 | background: #111827; 65 | color: hsl(0, 0%, 20%); 66 | border: 1px solid #374151 67 | 68 | &:hover { 69 | background: #0b0f19; 70 | border: 1px solid #272e3a; 71 | } 72 | `; 73 | 74 | const carouselBtnIcon = css` 75 | width: 24px; 76 | height: 24px; 77 | `; 78 | 79 | const carouselCard = css` 80 | padding: 8px; 81 | box-sizing: border-box; 82 | `; 83 | 84 | type CarouselProps = { 85 | reviews: GoogleReview[]; 86 | maxCharacters?: number; 87 | nameDisplay?: NameDisplay; 88 | logoVariant?: LogoVariant; 89 | dateDisplay?: DateDisplay; 90 | reviewVariant?: ReviewVariant; 91 | carouselSpeed?: number; 92 | carouselAutoplay?: boolean; 93 | maxItems?: number; 94 | theme?: Theme; 95 | accessibility?: boolean; 96 | readMoreLabel?: string; 97 | readLessLabel?: string; 98 | getAbsoluteDate?: (date: Date) => string; 99 | getRelativeDate?: (date: Date) => string; 100 | showDots?: boolean; 101 | }; 102 | 103 | export const Carousel: FC< 104 | CarouselProps & CarouselCSSProps & ReviewCardCSSProps 105 | > = ({ 106 | reviews, 107 | maxCharacters = 200, 108 | nameDisplay = "firstAndLastInitials", 109 | logoVariant = "icon", 110 | dateDisplay = "relative", 111 | reviewVariant = "card", 112 | carouselAutoplay = true, 113 | carouselSpeed = 3000, 114 | maxItems = 3, 115 | theme = "light", 116 | accessibility = true, 117 | readMoreLabel, 118 | readLessLabel, 119 | getAbsoluteDate, 120 | getRelativeDate, 121 | showDots = true, 122 | 123 | carouselClassName, 124 | carouselStyle, 125 | carouselBtnClassName, 126 | carouselBtnStyle, 127 | carouselBtnLeftClassName, 128 | carouselBtnLeftStyle, 129 | carouselBtnRightClassName, 130 | carouselBtnRightStyle, 131 | carouselBtnLightClassName, 132 | carouselBtnLightStyle, 133 | carouselBtnDarkClassName, 134 | carouselBtnDarkStyle, 135 | carouselBtnIconClassName, 136 | carouselBtnIconStyle, 137 | carouselCardClassName, 138 | carouselCardStyle, 139 | 140 | reviewCardClassName, 141 | reviewCardStyle, 142 | reviewCardLightClassName, 143 | reviewCardLightStyle, 144 | reviewCardDarkClassName, 145 | reviewCardDarkStyle, 146 | reviewBodyCardClassName, 147 | reviewBodyCardStyle, 148 | reviewBodyTestimonialClassName, 149 | reviewBodyTestimonialStyle, 150 | reviewTextClassName, 151 | reviewTextStyle, 152 | reviewTextLightClassName, 153 | reviewTextLightStyle, 154 | reviewTextDarkClassName, 155 | reviewTextDarkStyle, 156 | reviewReadMoreClassName, 157 | reviewReadMoreStyle, 158 | reviewReadMoreLightClassName, 159 | reviewReadMoreLightStyle, 160 | reviewReadMoreDarkClassName, 161 | reviewReadMoreDarkStyle, 162 | reviewFooterClassName, 163 | reviewFooterStyle, 164 | reviewerClassName, 165 | reviewerStyle, 166 | reviewerProfileClassName, 167 | reviewerProfileStyle, 168 | reviewerProfileImageClassName, 169 | reviewerProfileImageStyle, 170 | reviewerProfileFallbackClassName, 171 | reviewerProfileFallbackStyle, 172 | reviewerNameClassName, 173 | reviewerNameStyle, 174 | reviewerNameLightClassName, 175 | reviewerNameLightStyle, 176 | reviewerNameDarkClassName, 177 | reviewerNameDarkStyle, 178 | reviewerDateClassName, 179 | reviewerDateStyle, 180 | reviewerDateLightClassName, 181 | reviewerDateLightStyle, 182 | reviewerDateDarkClassName, 183 | reviewerDateDarkStyle, 184 | }) => { 185 | const slider = React.useRef(null); 186 | 187 | const autoplay = useMemo(() => { 188 | return carouselAutoplay == null ? true : carouselAutoplay; 189 | }, [carouselAutoplay]); 190 | 191 | const speed = useMemo(() => { 192 | return carouselSpeed == null ? 3000 : carouselSpeed; 193 | }, [carouselSpeed]); 194 | 195 | function extendArray(array: T[], targetLength: number): T[] { 196 | if (array.length === 0) { 197 | return []; 198 | } 199 | 200 | const result: T[] = [...array]; 201 | 202 | while (result.length < targetLength) { 203 | const remaining = targetLength - result.length; 204 | const itemsToCopy = Math.min(result.length, remaining); 205 | result.push(...result.slice(0, itemsToCopy)); 206 | } 207 | 208 | return result; 209 | } 210 | 211 | return ( 212 |
219 | 262 | 305 | ( 330 | 336 | )} 337 | responsive={[ 338 | { 339 | breakpoint: 1280, 340 | settings: { 341 | slidesToShow: 342 | reviews.length < maxItems 343 | ? reviews.length 344 | : maxItems, 345 | slidesToScroll: autoplay 346 | ? 1 347 | : reviews.length < maxItems 348 | ? reviews.length 349 | : maxItems, 350 | infinite: true, 351 | dots: showDots, 352 | }, 353 | }, 354 | { 355 | breakpoint: 768, 356 | settings: { 357 | slidesToShow: 358 | reviews.length < 2 359 | ? reviews.length 360 | : 2, 361 | slidesToScroll: autoplay 362 | ? 1 363 | : reviews.length < 2 364 | ? reviews.length 365 | : 2, 366 | initialSlide: reviews.length < 2 ? 1 : 2, 367 | }, 368 | }, 369 | { 370 | breakpoint: 640, 371 | settings: { 372 | slidesToShow: 1, 373 | slidesToScroll: 1, 374 | }, 375 | }, 376 | ]} 377 | > 378 | {(reviews.length < maxItems 379 | ? extendArray(reviews, maxItems) 380 | : reviews 381 | ).map((review, index) => { 382 | return ( 383 |
393 | 524 |
525 | ); 526 | })} 527 |
528 |
529 | ); 530 | }; 531 | -------------------------------------------------------------------------------- /src/components/ReactGoogleReviews/ReactGoogleReviews.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from '@emotion/react'; 4 | import React, { useCallback, useEffect, useState } from 'react'; 5 | import '../../css/index.css'; 6 | import { 7 | BadgeCSSProps, 8 | CarouselCSSProps, 9 | ErrorStateCSSProps, 10 | LoadingStateCSSProps, 11 | ReviewCardCSSProps, 12 | } from '../../types/cssProps'; 13 | import { 14 | DateDisplay, 15 | FeaturableAPIResponse, 16 | FeaturableAPIResponseV1, 17 | GoogleReview, 18 | LogoVariant, 19 | NameDisplay, 20 | ReviewVariant, 21 | Theme, 22 | WidgetVersion, 23 | } from '../../types/review'; 24 | import { Badge } from '../Badge/Badge'; 25 | import { Carousel } from '../Carousel/Carousel'; 26 | import { ErrorState } from '../State/ErrorState'; 27 | import { LoadingState } from '../State/LoadingState'; 28 | import { isV2Response, transformV2ResponseToV1 } from '../../utils/apiTransformers'; 29 | 30 | type StructuredDataProps = { 31 | /** 32 | * Total number of reviews. 33 | * This is automatically fetched when passing `featurableId`. 34 | * Required if `structuredData` is true and passing `reviews`. 35 | */ 36 | totalReviewCount?: number; 37 | 38 | /** 39 | * Average star rating from 1 to 5. 40 | * This is automatically fetched when passing `featurableId`. 41 | * Required if `structuredData` is true and passing `reviews`. 42 | */ 43 | averageRating?: number; 44 | }; 45 | 46 | type ReactGoogleReviewsBaseProps = { 47 | /** 48 | * Layout of the reviews. 49 | */ 50 | layout: 'carousel' | 'badge' | 'custom'; 51 | 52 | /** 53 | * How to display the reviewer's name. 54 | * Default: "firstAndLastInitials" 55 | */ 56 | nameDisplay?: NameDisplay; 57 | 58 | /** 59 | * How to display the Google logo 60 | * Default: "icon" 61 | */ 62 | logoVariant?: LogoVariant; 63 | 64 | /** 65 | * When collapsed, the maximum number of characters to display in the review body. 66 | * Default: 200 67 | */ 68 | maxCharacters?: number; 69 | 70 | /** 71 | * How to display the review date. 72 | * Default: "relative" 73 | */ 74 | dateDisplay?: DateDisplay; 75 | 76 | /** 77 | * Review card layout variant. 78 | * Default: "card" 79 | */ 80 | reviewVariant?: ReviewVariant; 81 | 82 | /** 83 | * Color scheme of the component. 84 | * Default: "light" 85 | */ 86 | theme?: Theme; 87 | 88 | /** 89 | * Enable or disable structured data. 90 | * Default: false 91 | */ 92 | structuredData?: boolean; 93 | 94 | /** 95 | * Brand name for structured data. 96 | * Default: current page title 97 | */ 98 | brandName?: string; 99 | 100 | /** 101 | * Product/service name for structured data. 102 | * Default: current page title 103 | */ 104 | productName?: string; 105 | 106 | /** 107 | * Short description of the product/service for structured data. 108 | * Default: empty 109 | */ 110 | productDescription?: string; 111 | 112 | /** 113 | * Enable/disable accessibility features. 114 | */ 115 | accessibility?: boolean; 116 | 117 | /** 118 | * Hide reviews without text 119 | * Default: false 120 | */ 121 | hideEmptyReviews?: boolean; 122 | 123 | /** 124 | * Disables translation from Google to use original review text 125 | * Default: false 126 | */ 127 | disableTranslation?: boolean; 128 | 129 | /** 130 | * Customize the error message when reviews fail to load. 131 | * Default: "Failed to load Google reviews. Please try again later." 132 | */ 133 | errorMessage?: React.ReactNode; 134 | 135 | /** 136 | * Customize the loading message when reviews are loading. 137 | * Default: "Loading reviews..." 138 | */ 139 | loadingMessage?: React.ReactNode; 140 | } & StructuredDataProps; 141 | 142 | type ReactGoogleReviewsWithPlaceIdBaseProps = ReactGoogleReviewsBaseProps & { 143 | /** 144 | * If using Google Places API, use `dangerouslyFetchPlaceDetails` to get reviews server-side and pass them to the client. 145 | * Note: the Places API limits the number of reviews to FIVE most recent reviews. 146 | */ 147 | reviews: GoogleReview[]; 148 | featurableId?: never; 149 | 150 | /** 151 | * Controls the loading state of the component when fetching reviews manually. 152 | */ 153 | isLoading?: boolean; 154 | }; 155 | 156 | type ReactGoogleReviewsWithPlaceIdWithStructuredDataProps = { 157 | structuredData: true; 158 | } & Required; 159 | 160 | type ReactGoogleReviewsWithPlaceIdWithoutStructuredDataProps = { 161 | structuredData?: false; 162 | }; 163 | 164 | type ReactGoogleReviewsWithPlaceIdProps = ReactGoogleReviewsWithPlaceIdBaseProps & 165 | (ReactGoogleReviewsWithPlaceIdWithStructuredDataProps | ReactGoogleReviewsWithPlaceIdWithoutStructuredDataProps); 166 | 167 | type ReactGoogleReviewsWithFeaturableIdProps = ReactGoogleReviewsBaseProps & { 168 | reviews?: never; 169 | /** 170 | * If using Featurable API, pass the ID of the widget after setting it up in the dashboard. 171 | * Using the free Featurable API allows for unlimited reviews. 172 | * https://featurable.com/app/widgets 173 | */ 174 | featurableId: string; 175 | 176 | isLoading?: never; 177 | 178 | /** 179 | * Version of the Featurable widget to use 180 | * Default: "v1" 181 | */ 182 | widgetVersion?: WidgetVersion; 183 | }; 184 | 185 | type ReactGoogleReviewsBasePropsWithRequired = ReactGoogleReviewsBaseProps & 186 | (ReactGoogleReviewsWithPlaceIdProps | ReactGoogleReviewsWithFeaturableIdProps) & 187 | ErrorStateCSSProps & 188 | LoadingStateCSSProps; 189 | 190 | type ReactGoogleReviewsCarouselProps = ReactGoogleReviewsBasePropsWithRequired & { 191 | layout: 'carousel'; 192 | /** 193 | * Autoplay speed of the carousel in milliseconds. 194 | * Default: 3000 195 | */ 196 | carouselSpeed?: number; 197 | 198 | /** 199 | * Whether to autoplay the carousel. 200 | * Default: true 201 | */ 202 | carouselAutoplay?: boolean; 203 | 204 | /** 205 | * Maximum number of items to display at any one time. 206 | * Default: 3 207 | */ 208 | maxItems?: number; 209 | 210 | /** 211 | * Read more label for truncated reviews. 212 | * Default: "Read more" 213 | */ 214 | readMoreLabel?: string; 215 | 216 | /** 217 | * Read less label for expanded reviews. 218 | * Default: "Read less" 219 | */ 220 | readLessLabel?: string; 221 | 222 | /** 223 | * Formatting function for relative dates. 224 | * Default: defaultGetRelativeDate 225 | */ 226 | getRelativeDate?: (date: Date) => string; 227 | 228 | /** 229 | * Formatting function for absolute dates. 230 | * Default: (date) => date.toLocaleDateString() 231 | */ 232 | getAbsoluteDate?: (date: Date) => string; 233 | 234 | /** 235 | * Show/hide navigation dots on the carousel 236 | * Default: true 237 | */ 238 | showDots?: boolean; 239 | } & CarouselCSSProps & 240 | ReviewCardCSSProps; 241 | 242 | type ReactGoogleReviewsBadgeProps = ReactGoogleReviewsBasePropsWithRequired & { 243 | layout: 'badge'; 244 | 245 | /** 246 | * Google profile URL, if manually fetching Google Places API and passing `reviews`. 247 | * This is automatically fetched when passing `featurableId`. 248 | */ 249 | profileUrl?: string; 250 | 251 | /** 252 | * Label for the badge. 253 | * Default: "Google Rating" 254 | */ 255 | badgeLabel?: string; 256 | 257 | /** 258 | * Function to format the badge subheading. 259 | * Default: (totalReviewCount) => `Read our ${totalReviewCount} reviews` 260 | */ 261 | badgeSubheadingFormatter?: (totalReviewCount: number) => string; 262 | } & BadgeCSSProps; 263 | 264 | type ReactGoogleReviewsCustomProps = ReactGoogleReviewsBasePropsWithRequired & { 265 | layout: 'custom'; 266 | renderer: (reviews: GoogleReview[]) => React.ReactNode; 267 | }; 268 | 269 | type ReactGoogleReviewsProps = 270 | | ReactGoogleReviewsCarouselProps 271 | | ReactGoogleReviewsCustomProps 272 | | ReactGoogleReviewsBadgeProps; 273 | 274 | const parent = css` 275 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 276 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 277 | `; 278 | 279 | const ReactGoogleReviews: React.FC = ({ ...props }) => { 280 | if (props.totalReviewCount != null && (props.totalReviewCount < 0 || !Number.isInteger(props.totalReviewCount))) { 281 | throw new Error('totalReviewCount must be a positive integer'); 282 | } 283 | if (props.averageRating != null && (props.averageRating < 1 || props.averageRating > 5)) { 284 | throw new Error('averageRating must be between 1 and 5'); 285 | } 286 | 287 | const mapReviews = useCallback( 288 | (review: GoogleReview): GoogleReview => { 289 | let comment = review.comment; 290 | if (props.disableTranslation) { 291 | if (review.comment.includes('(Original)')) { 292 | const split = review.comment.split('(Original)'); 293 | if (split.length > 1) { 294 | comment = split[1].trim(); 295 | } 296 | } else if (review.comment.includes('(Translated by Google)')) { 297 | const split = review.comment.split('(Translated by Google)'); 298 | if (split.length > 1) { 299 | comment = split[0].trim(); 300 | } 301 | } 302 | } 303 | return { 304 | ...review, 305 | comment, 306 | }; 307 | }, 308 | [props.disableTranslation] 309 | ); 310 | 311 | const filterReviews = useCallback( 312 | (review: GoogleReview): boolean => { 313 | if (props.hideEmptyReviews) { 314 | return review.comment.trim().length !== 0; 315 | } 316 | return true; 317 | }, 318 | [props.hideEmptyReviews] 319 | ); 320 | 321 | const [reviews, setReviews] = useState(props.reviews?.filter(filterReviews).map(mapReviews) ?? []); 322 | const [loading, setLoading] = useState(true); 323 | const [error, setError] = useState(false); 324 | const [profileUrl, setProfileUrl] = useState( 325 | props.layout === 'badge' ? props.profileUrl ?? null : null 326 | ); 327 | const [totalReviewCount, setTotalReviewCount] = useState(props.totalReviewCount ?? null); 328 | const [averageRating, setAverageRating] = useState(props.averageRating ?? null); 329 | 330 | useEffect(() => { 331 | // update reviews when props change 332 | if (props.reviews) { 333 | setReviews(props.reviews.filter(filterReviews).map(mapReviews)); 334 | } 335 | }, [props.reviews, filterReviews, mapReviews]); 336 | 337 | useEffect(() => { 338 | if (props.featurableId) { 339 | // Default to v1 for backwards compatability 340 | const version = props.widgetVersion ?? 'v1'; 341 | const apiUrl = `https://featurable.com/api/${version}/widgets/${props.featurableId}`; 342 | 343 | fetch(apiUrl, { 344 | method: 'GET', 345 | }) 346 | .then((res) => res.json()) 347 | .then((data: FeaturableAPIResponse) => { 348 | // Transform v2 response to v1 format for backwards compatibility 349 | let normalizedData: FeaturableAPIResponseV1; 350 | if (version === 'v2' && isV2Response(data)) { 351 | normalizedData = transformV2ResponseToV1(data); 352 | } else { 353 | normalizedData = data as FeaturableAPIResponseV1; 354 | } 355 | 356 | if (!normalizedData.success) { 357 | setError(true); 358 | return; 359 | } 360 | 361 | setReviews(normalizedData.reviews.filter(filterReviews).map(mapReviews)); 362 | setProfileUrl(normalizedData.profileUrl); 363 | setTotalReviewCount(normalizedData.totalReviewCount); 364 | setAverageRating(normalizedData.averageRating); 365 | }) 366 | .catch(() => { 367 | setError(true); 368 | }) 369 | .finally(() => setLoading(false)); 370 | } else { 371 | setLoading(false); 372 | } 373 | }, [props.featurableId, filterReviews, mapReviews]); 374 | 375 | if ((loading && typeof props.isLoading === 'undefined') || props.isLoading) { 376 | return ( 377 | 386 | ); 387 | } 388 | 389 | if (error || (props.layout === 'badge' && (averageRating === null || totalReviewCount === null))) { 390 | return ( 391 | 396 | ); 397 | } 398 | 399 | return ( 400 |
401 | {props.structuredData && averageRating !== null && totalReviewCount !== null && ( 402 | 435 | )} 436 | 437 | {props.layout === 'carousel' && ( 438 | 517 | )} 518 | 519 | {props.layout === 'badge' && ( 520 | 572 | )} 573 | 574 | {props.layout === 'custom' && props.renderer(reviews)} 575 |
576 | ); 577 | }; 578 | 579 | export default ReactGoogleReviews; 580 | -------------------------------------------------------------------------------- /src/components/ReviewCard/ReviewCard.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxImportSource @emotion/react */ 2 | 3 | import { css } from "@emotion/react"; 4 | import clsx from "clsx"; 5 | import React, { FC, useEffect, useMemo, useState } from "react"; 6 | import { ReviewCardCSSProps } from "../../types/cssProps"; 7 | import { 8 | DateDisplay, 9 | GoogleReview, 10 | LogoVariant, 11 | NameDisplay, 12 | ReviewVariant, 13 | Theme, 14 | } from "../../types/review"; 15 | import { displayName } from "../../utils/displayName"; 16 | import { getRelativeDate as defaultGetRelativeDate } from "../../utils/getRelativeDate"; 17 | import { trim } from "../../utils/trim"; 18 | import { GoogleIcon } from "../Google/GoogleIcon"; 19 | import { GoogleLogo } from "../Google/GoogleLogo"; 20 | import { StarRating } from "../StarRating/StarRating"; 21 | 22 | const reviewCard = css` 23 | max-width: 65ch; 24 | margin: 0 auto; 25 | height: 100%; 26 | width: 100%; 27 | box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 28 | 0 1px 2px -1px rgb(0 0 0 / 0.1); 29 | border-radius: 8px; 30 | display: flex; 31 | flex-direction: column; 32 | justify-content: space-between; 33 | transition: all; 34 | padding: 12px; 35 | box-sizing: border-box; 36 | `; 37 | 38 | const reviewCardLight = css` 39 | background: white; 40 | border: 1px solid #e5e7eb; 41 | `; 42 | 43 | const reviewCardDark = css` 44 | background: #111827; 45 | border: 1px solid #374151; 46 | `; 47 | 48 | const reviewBodyCard = css` 49 | margin-top: 16px; 50 | `; 51 | 52 | const reviewBodyTestimonial = css` 53 | margin-top: 20px; 54 | `; 55 | 56 | const reviewText = css` 57 | line-height: 1.5; 58 | margin: 0; 59 | font-size: 16px; 60 | `; 61 | 62 | const reviewTextLight = css` 63 | color: #030712; 64 | `; 65 | const reviewTextDark = css` 66 | color: white; 67 | `; 68 | 69 | const readMore = css` 70 | margin-top: 4px; 71 | display: inline-block; 72 | text-decoration: none; 73 | border: none; 74 | background: none; 75 | outline: none; 76 | font-size: 16px; 77 | cursor: pointer; 78 | 79 | &:hover { 80 | text-decoration: underline; 81 | } 82 | `; 83 | 84 | const readMoreLight = css` 85 | color: #6b7280; 86 | `; 87 | 88 | const readMoreDark = css` 89 | color: #9ca3af; 90 | `; 91 | 92 | const footer = css` 93 | display: flex; 94 | align-items: center; 95 | justify-content: space-between; 96 | margin-top: 16px; 97 | `; 98 | 99 | type ReviewCardProps = { 100 | review: GoogleReview; 101 | maxCharacters?: number; 102 | nameDisplay?: NameDisplay; 103 | logoVariant?: LogoVariant; 104 | dateDisplay?: DateDisplay; 105 | reviewVariant?: ReviewVariant; 106 | theme?: Theme; 107 | readMoreLabel?: string; 108 | readLessLabel?: string; 109 | getAbsoluteDate?: (date: Date) => string; 110 | getRelativeDate?: (date: Date) => string; 111 | }; 112 | 113 | export const ReviewCard: FC = ({ 114 | review, 115 | maxCharacters = 200, 116 | nameDisplay = "firstAndLastInitials", 117 | logoVariant = "icon", 118 | dateDisplay = "relative", 119 | reviewVariant = "card", 120 | theme = "light", 121 | readMoreLabel = "Read more", 122 | readLessLabel = "Read less", 123 | getAbsoluteDate, 124 | getRelativeDate, 125 | reviewCardClassName, 126 | reviewCardStyle, 127 | reviewCardLightClassName, 128 | reviewCardLightStyle, 129 | reviewCardDarkClassName, 130 | reviewCardDarkStyle, 131 | reviewBodyCardClassName, 132 | reviewBodyCardStyle, 133 | reviewBodyTestimonialClassName, 134 | reviewBodyTestimonialStyle, 135 | reviewTextClassName, 136 | reviewTextStyle, 137 | reviewTextLightClassName, 138 | reviewTextLightStyle, 139 | reviewTextDarkClassName, 140 | reviewTextDarkStyle, 141 | reviewReadMoreClassName, 142 | reviewReadMoreStyle, 143 | reviewReadMoreLightClassName, 144 | reviewReadMoreLightStyle, 145 | reviewReadMoreDarkClassName, 146 | reviewReadMoreDarkStyle, 147 | reviewFooterClassName, 148 | reviewFooterStyle, 149 | reviewerClassName, 150 | reviewerStyle, 151 | reviewerProfileClassName, 152 | reviewerProfileStyle, 153 | reviewerProfileImageClassName, 154 | reviewerProfileImageStyle, 155 | reviewerProfileFallbackClassName, 156 | reviewerProfileFallbackStyle, 157 | reviewerNameClassName, 158 | reviewerNameStyle, 159 | reviewerNameLightClassName, 160 | reviewerNameLightStyle, 161 | reviewerNameDarkClassName, 162 | reviewerNameDarkStyle, 163 | reviewerDateClassName, 164 | reviewerDateStyle, 165 | reviewerDateLightClassName, 166 | reviewerDateLightStyle, 167 | reviewerDateDarkClassName, 168 | reviewerDateDarkStyle, 169 | }) => { 170 | const [isOpen, setIsOpen] = useState(false); 171 | 172 | const hasMore = useMemo(() => { 173 | return review.comment.length > maxCharacters; 174 | }, [review.comment, maxCharacters]); 175 | 176 | const comment = useMemo(() => { 177 | if (isOpen) { 178 | return review.comment; 179 | } else { 180 | return trim(review.comment, maxCharacters); 181 | } 182 | }, [isOpen, review.comment, maxCharacters, hasMore]); 183 | 184 | return ( 185 |
202 |
203 | {reviewVariant === "card" && ( 204 | 254 | )} 255 | 256 | {reviewVariant === "testimonial" && ( 257 | 258 | )} 259 | 260 |
277 |

300 | {comment} 301 |

302 | 303 | {hasMore && ( 304 | 327 | )} 328 |
329 |
330 | 331 |
336 | {reviewVariant === "card" && ( 337 | 338 | )} 339 | 340 | {reviewVariant === "testimonial" && ( 341 | 389 | )} 390 | 391 | {logoVariant === "full" && } 392 | {logoVariant === "icon" && } 393 |
394 |
395 | ); 396 | }; 397 | 398 | const reviewer = css` 399 | display: flex; 400 | align-items: center; 401 | `; 402 | 403 | const reviewerProfile = css` 404 | position: relative; 405 | border-radius: 100%; 406 | width: 40px; 407 | height: 40px; 408 | margin-right: 12px; 409 | `; 410 | 411 | const reviewerProfileImage = css` 412 | border-radius: 100%; 413 | width: 100%; 414 | height: 100%; 415 | `; 416 | 417 | const reviewerProfileFallback = css` 418 | position: absolute; 419 | left: 0; 420 | right: 0; 421 | top: 0; 422 | bottom: 0; 423 | display: flex; 424 | align-items: center; 425 | justify-content: center; 426 | font-weight: 500; 427 | color: white; 428 | border-radius: 100%; 429 | font-size: 20px; 430 | `; 431 | 432 | const reviewerName = css` 433 | font-weight: 600; 434 | font-size: 16px; 435 | margin: 0; 436 | `; 437 | 438 | const reviewerNameLight = css` 439 | color: #030712; 440 | `; 441 | 442 | const reviewerNameDark = css` 443 | color: #ffffff; 444 | `; 445 | 446 | const reviewerDate = css` 447 | font-size: 16px; 448 | margin: 0; 449 | `; 450 | const reviewerDateLight = css` 451 | color: #6b7280; 452 | `; 453 | const reviewerDateDark = css` 454 | color: #9ca3af; 455 | `; 456 | 457 | type ReviewCardReviewerProps = { 458 | review: GoogleReview; 459 | nameDisplay: NameDisplay; 460 | dateDisplay: DateDisplay; 461 | theme?: Theme; 462 | getAbsoluteDate?: (date: Date) => string; 463 | getRelativeDate?: (date: Date) => string; 464 | }; 465 | 466 | const ReviewCardReviewer: React.FC< 467 | ReviewCardReviewerProps & ReviewCardCSSProps 468 | > = ({ 469 | review, 470 | nameDisplay, 471 | dateDisplay, 472 | theme = "light", 473 | getAbsoluteDate = (date) => 474 | date.toLocaleDateString(undefined, { 475 | year: "numeric", 476 | month: "long", 477 | day: "numeric", 478 | }), 479 | getRelativeDate = defaultGetRelativeDate, 480 | 481 | reviewerClassName, 482 | reviewerStyle, 483 | reviewerProfileClassName, 484 | reviewerProfileStyle, 485 | reviewerProfileImageClassName, 486 | reviewerProfileImageStyle, 487 | reviewerProfileFallbackClassName, 488 | reviewerProfileFallbackStyle, 489 | reviewerNameClassName, 490 | reviewerNameStyle, 491 | reviewerNameLightClassName, 492 | reviewerNameLightStyle, 493 | reviewerNameDarkClassName, 494 | reviewerNameDarkStyle, 495 | reviewerDateClassName, 496 | reviewerDateStyle, 497 | reviewerDateLightClassName, 498 | reviewerDateLightStyle, 499 | reviewerDateDarkClassName, 500 | reviewerDateDarkStyle, 501 | }) => { 502 | const [fallback, setFallback] = useState(false); 503 | 504 | useEffect(() => { 505 | if (review.reviewer.isAnonymous) { 506 | setFallback(true); 507 | } else if (!review.reviewer.profilePhotoUrl) { 508 | setFallback(true); 509 | } 510 | }, [review.reviewer]); 511 | 512 | const getFallbackBgColor = (char: string): string => { 513 | switch (char) { 514 | case "a": 515 | return "#660091"; 516 | case "b": 517 | return "#4B53B2"; 518 | case "c": 519 | return "#B1004A"; 520 | case "d": 521 | return "#972BB0"; 522 | case "e": 523 | return "#0F72C8"; 524 | case "f": 525 | return "#094389"; 526 | case "g": 527 | return "#118797"; 528 | case "h": 529 | return "#0F7868"; 530 | case "i": 531 | return "#073D30"; 532 | case "j": 533 | return "#57922C"; 534 | case "k": 535 | return "#E42567"; 536 | case "l": 537 | return "#364852"; 538 | case "m": 539 | return "#295817"; 540 | case "n": 541 | return "#795B50"; 542 | case "o": 543 | return "#4A322B"; 544 | case "p": 545 | return "#693EB4"; 546 | case "q": 547 | return "#3E1796"; 548 | case "r": 549 | return "#E95605"; 550 | case "s": 551 | return "#F03918"; 552 | case "t": 553 | return "#AE230D"; 554 | case "u": 555 | case "v": 556 | return "#647F8C"; 557 | case "w": 558 | case "x": 559 | case "y": 560 | case "z": 561 | default: 562 | return "#6b7280"; 563 | } 564 | }; 565 | 566 | return ( 567 |
572 |
577 | {!review.reviewer.isAnonymous && 578 | review.reviewer.profilePhotoUrl && ( 579 | { 582 | setFallback(true); 583 | }} 584 | css={reviewerProfileImage} 585 | className={reviewerProfileImageClassName} 586 | style={reviewerProfileImageStyle} 587 | /> 588 | )} 589 | 590 | {fallback && ( 591 |
603 | {review.reviewer.isAnonymous 604 | ? "A" 605 | : review.reviewer.displayName[0].toUpperCase()} 606 |
607 | )} 608 |
609 |
610 |

630 | {review.reviewer.isAnonymous 631 | ? "Anonymous" 632 | : displayName( 633 | review.reviewer.displayName, 634 | nameDisplay 635 | )} 636 |

637 | 638 | {(review.updateTime || review.createTime) && ( 639 |

660 | {dateDisplay === "absolute" 661 | ? getAbsoluteDate( 662 | new Date( 663 | review.updateTime ?? 664 | review.createTime ?? 665 | "" 666 | ) 667 | ) 668 | : getRelativeDate( 669 | new Date( 670 | review.updateTime ?? 671 | review.createTime ?? 672 | "" 673 | ) 674 | )} 675 |

676 | )} 677 |
678 |
679 | ); 680 | }; 681 | --------------------------------------------------------------------------------