├── .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 |
72 |
76 |
80 |
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 |
288 |
295 |
305 | ★★★★★
306 |
307 |
314 | ★★★★★
315 |
316 |
317 |
318 |
319 |
384 |
385 |
386 |
387 | );
388 | };
389 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # React Google Reviews
4 |
5 | 
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 | [](https://www.npmjs.com/package/react-google-reviews)
22 | [](https://github.com/Featurable/react-google-reviews/releases)
23 | [](https://github.com/Featurable/react-google-reviews/releases/latest)
24 | [](https://github.com/Featurable/react-google-reviews/blob/main/LICENSE)
25 | [](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 |
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 | 
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 |
155 |
156 | 2. **Carousel**: An interactive carousel that displays reviews
157 |
158 | ```jsx
159 |
160 | ```
161 |
162 | 
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 |
slider?.current?.slickPrev()}
221 | css={[
222 | carouselBtn,
223 | carouselBtnLeft,
224 | theme === "light"
225 | ? carouselBtnLight
226 | : carouselBtnDark,
227 | ]}
228 | role="button"
229 | aria-description="Previous Review"
230 | className={clsx(
231 | carouselBtnClassName,
232 | carouselBtnLeftClassName,
233 | theme === "light"
234 | ? carouselBtnLightClassName
235 | : carouselBtnDarkClassName
236 | )}
237 | style={{
238 | ...carouselBtnStyle,
239 | ...carouselBtnLeftStyle,
240 | ...(theme === "light"
241 | ? carouselBtnLightStyle
242 | : carouselBtnDarkStyle),
243 | }}
244 | >
245 |
255 |
260 |
261 |
262 |
slider?.current?.slickNext()}
264 | css={[
265 | carouselBtn,
266 | carouselBtnRight,
267 | theme === "light"
268 | ? carouselBtnLight
269 | : carouselBtnDark,
270 | ]}
271 | role="button"
272 | aria-description="Next Review"
273 | className={clsx(
274 | carouselBtnClassName,
275 | carouselBtnRightClassName,
276 | theme === "light"
277 | ? carouselBtnLightClassName
278 | : carouselBtnDarkClassName
279 | )}
280 | style={{
281 | ...carouselBtnStyle,
282 | ...carouselBtnRightStyle,
283 | ...(theme === "light"
284 | ? carouselBtnLightStyle
285 | : carouselBtnDarkStyle),
286 | }}
287 | >
288 |
298 |
303 |
304 |
305 |
(
330 |
334 | {i + 1}
335 |
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 |
setIsOpen(!isOpen)}
306 | css={[
307 | readMore,
308 | theme === "light" && readMoreLight,
309 | theme === "dark" && readMoreDark,
310 | ]}
311 | className={clsx(
312 | reviewReadMoreClassName,
313 | theme === "light" &&
314 | reviewReadMoreLightClassName,
315 | theme === "dark" &&
316 | reviewReadMoreDarkClassName
317 | )}
318 | style={{
319 | ...reviewReadMoreStyle,
320 | ...(theme === "light"
321 | ? reviewReadMoreLightStyle
322 | : reviewReadMoreDarkStyle),
323 | }}
324 | >
325 | {isOpen ? readLessLabel : readMoreLabel}
326 |
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 |
--------------------------------------------------------------------------------