├── .eslintrc.json ├── postcss.config.js ├── .dockerignore ├── app ├── api │ ├── reviews │ │ ├── review.d.ts │ │ └── route.ts │ └── products │ │ ├── product.d.ts │ │ └── route.ts ├── not-found.tsx ├── _components │ ├── reviews.tsx │ ├── recommanded-products.tsx │ └── single-product.tsx ├── page.tsx └── layout.tsx ├── lib └── getBaseUrl.ts ├── ui ├── product-best-seller.tsx ├── ping.tsx ├── product-low-stock-warning.tsx ├── product-rating.tsx ├── product-currency-symbol.tsx ├── product-used-price.tsx ├── product-estimated-arrival.tsx ├── product-review-card.tsx ├── product-lightening-deal.tsx ├── product-deal.tsx ├── product-price.tsx ├── product-card.tsx └── boundary.tsx ├── CODE_OF_CONDUCT.md ├── next.config.js ├── .gitignore ├── styles └── globals.css ├── Dockerfile ├── tsconfig.json ├── LICENSE ├── template.yaml ├── README.md ├── package.json ├── tailwind.config.js └── CONTRIBUTING.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .next 3 | .git 4 | .gitignore 5 | 6 | node_modules 7 | 8 | # aws sam 9 | .aws-sam 10 | samconfig.toml -------------------------------------------------------------------------------- /app/api/reviews/review.d.ts: -------------------------------------------------------------------------------- 1 | export type Review = { 2 | id: string 3 | name: string 4 | rating: number 5 | text: string 6 | } 7 | -------------------------------------------------------------------------------- /lib/getBaseUrl.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | 3 | export const getBaseUrl = cache(() => 4 | process.env.VERCEL_URL 5 | ? `https://app-dir.vercel.app` 6 | : `http://127.0.0.1:${process.env.PORT ?? 3000}`, 7 | ); 8 | -------------------------------------------------------------------------------- /ui/product-best-seller.tsx: -------------------------------------------------------------------------------- 1 | export const ProductBestSeller = () => { 2 | return ( 3 |
4 | Best Seller 5 |
6 | ); 7 | }; -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | compress: true, 7 | 8 | images: { 9 | remotePatterns: [ 10 | { 11 | protocol: "https", 12 | hostname: "images.unsplash.com" 13 | } 14 | ] 15 | } 16 | } 17 | 18 | module.exports = nextConfig 19 | -------------------------------------------------------------------------------- /ui/ping.tsx: -------------------------------------------------------------------------------- 1 | export function Ping() { 2 | return ( 3 | 4 | 5 | 6 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /ui/product-low-stock-warning.tsx: -------------------------------------------------------------------------------- 1 | export const ProductLowStockWarning = ({ stock }: { stock: number }) => { 2 | if (stock > 3) { 3 | return null; 4 | } 5 | 6 | if (stock === 0) { 7 | return
Out of stock
; 8 | } 9 | 10 | return ( 11 |
Only {stock} left in stock
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Boundary } from '@/ui/boundary'; 2 | 3 | export default function NotFound() { 4 | return ( 5 | 6 |
7 |

Not Found

8 | 9 |

Could not find requested resource

10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | .vscode 3 | 4 | # aws sam 5 | ./.aws-sam/ 6 | samconfig.toml 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # next.js 12 | .next 13 | out 14 | 15 | # misc 16 | .DS_Store 17 | *.pem 18 | 19 | # debug 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # local env files 25 | .env*.local 26 | 27 | # vercel 28 | .vercel 29 | 30 | # typescript 31 | *.tsbuildinfo 32 | next-env.d.ts 33 | -------------------------------------------------------------------------------- /ui/product-rating.tsx: -------------------------------------------------------------------------------- 1 | import { StarIcon } from '@heroicons/react/solid'; 2 | import clsx from 'clsx'; 3 | 4 | export const ProductRating = ({ rating }: { rating: number }) => { 5 | return ( 6 |
7 | {Array.from({ length: 5 }).map((_, i) => { 8 | return ( 9 | 13 | ); 14 | })} 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /ui/product-currency-symbol.tsx: -------------------------------------------------------------------------------- 1 | import { toFormat, type Dinero } from 'dinero.js'; 2 | 3 | export const ProductCurrencySymbol = ({ 4 | dinero, 5 | }: { 6 | dinero: Dinero; 7 | }) => { 8 | let symbol = ''; 9 | switch (toFormat(dinero, ({ currency }) => currency.code)) { 10 | case 'GBP': { 11 | symbol = '£'; 12 | break; 13 | } 14 | 15 | case 'EUR': { 16 | symbol = '€'; 17 | break; 18 | } 19 | 20 | default: { 21 | symbol = '$'; 22 | break; 23 | } 24 | } 25 | 26 | return <>{symbol}; 27 | }; 28 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } */ -------------------------------------------------------------------------------- /ui/product-used-price.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '@/app/api/products/product'; 2 | import { dinero, toUnit, up, type DineroSnapshot } from 'dinero.js'; 3 | 4 | export const ProductUsedPrice = ({ 5 | usedPrice: usedPriceRaw, 6 | }: { 7 | usedPrice: Product['usedPrice']; 8 | }) => { 9 | const usedPrice = dinero(usedPriceRaw as DineroSnapshot); 10 | 11 | return ( 12 |
13 |
More buying choices
14 |
15 | ${toUnit(usedPrice, { digits: 0, round: up })} (used) 16 |
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/lambda/nodejs:16 as builder 2 | WORKDIR /app 3 | 4 | COPY . . 5 | RUN npm update && npm run build 6 | 7 | FROM public.ecr.aws/lambda/nodejs:16 as runner 8 | COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.0 /lambda-adapter /opt/extensions/lambda-adapter 9 | 10 | ENV PORT=3000 NODE_ENV=production 11 | 12 | WORKDIR ${LAMBDA_TASK_ROOT} 13 | COPY --from=builder /app/.next ./.next 14 | COPY --from=builder /app/node_modules ./node_modules 15 | COPY --from=builder /app/package.json ./package.json 16 | COPY --from=builder /app/next.config.js ./next.config.js 17 | RUN ln -s /tmp/cache ./.next/cache 18 | 19 | ENTRYPOINT ["npm", "run", "start", "--loglevel=verbose", "--cache=/tmp/npm"] -------------------------------------------------------------------------------- /ui/product-estimated-arrival.tsx: -------------------------------------------------------------------------------- 1 | import { add, format, isTomorrow } from 'date-fns'; 2 | 3 | export const ProductEstimatedArrival = ({ 4 | leadTime, 5 | hasDeliveryTime = false, 6 | }: { 7 | leadTime: number; 8 | hasDeliveryTime?: boolean; 9 | }) => { 10 | const date = add(new Date(), { 11 | days: leadTime, 12 | }); 13 | 14 | return ( 15 |
16 | Get it{' '} 17 | 18 | {isTomorrow(date) ? 'tomorrow, ' : null} 19 | {format(date, 'MMM d')} 20 | 21 | {hasDeliveryTime ? <> by 5pm : null} 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/api/products/product.d.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | id: string; 3 | stock: number; 4 | rating: number; 5 | name: string; 6 | description: string; 7 | price: Price; 8 | isBestSeller: boolean; 9 | leadTime: number; 10 | image?: string; 11 | imageBlur?: string; 12 | discount?: Discount; 13 | usedPrice?: UsedPrice; 14 | }; 15 | 16 | type Price = { 17 | amount: number; 18 | currency: Currency; 19 | scale: number; 20 | }; 21 | 22 | type Currency = { 23 | code: string; 24 | base: number; 25 | exponent: number; 26 | }; 27 | 28 | type Discount = { 29 | percent: number; 30 | expires?: number; 31 | }; 32 | 33 | type UsedPrice = { 34 | amount: number; 35 | currency: Currency; 36 | scale: number; 37 | }; 38 | -------------------------------------------------------------------------------- /ui/product-review-card.tsx: -------------------------------------------------------------------------------- 1 | import type { Review } from '@/app/api/reviews/review'; 2 | import { ProductRating } from '@/ui/product-rating'; 3 | 4 | export const ProductReviewCard = ({ review }: { review: Review }) => { 5 | return ( 6 |
7 |
8 |
9 |
10 |
{review.name}
11 |
12 | 13 | {review.rating ? : null} 14 |
15 | 16 |
{review.text}
17 |
18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /ui/product-lightening-deal.tsx: -------------------------------------------------------------------------------- 1 | import { ProductDeal } from '@/ui/product-deal'; 2 | import { add, formatDistanceToNow } from 'date-fns'; 3 | import { type Dinero } from 'dinero.js'; 4 | 5 | export const ProductLighteningDeal = ({ 6 | price, 7 | discount, 8 | }: { 9 | price: Dinero; 10 | discount: { 11 | amount: Dinero; 12 | expires?: number; 13 | }; 14 | }) => { 15 | const date = add(new Date(), { days: discount.expires }); 16 | 17 | return ( 18 | <> 19 |
20 |
21 | Expires in {formatDistanceToNow(date)} 22 |
23 |
24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: "Sample lambda streaming response SAM template using nextjs" 4 | Globals: 5 | Function: 6 | Timeout: 60 7 | 8 | Resources: 9 | StreamingNextjsFunction: 10 | Type: AWS::Serverless::Function 11 | Properties: 12 | MemorySize: 256 13 | PackageType: Image 14 | Architectures: 15 | - x86_64 16 | Environment: 17 | Variables: 18 | AWS_LWA_INVOKE_MODE: response_stream 19 | FunctionUrlConfig: 20 | AuthType: NONE 21 | InvokeMode: RESPONSE_STREAM 22 | Metadata: 23 | DockerTag: v1 24 | DockerContext: ./ 25 | Dockerfile: Dockerfile 26 | 27 | Outputs: 28 | StreamingNextjsFunctionOutput: 29 | Description: "Streaming Nextjs Function ARN" 30 | Value: !GetAtt StreamingNextjsFunction.Arn 31 | StreamingNextjsFunctionUrlOutput: 32 | Description: "nextjs streaming response function url" 33 | Value: !GetAtt StreamingNextjsFunctionUrl.FunctionUrl 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nextjs response streaming example 2 | 3 | This example show how to use [Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) to run a nextjs application with response streaming via a [AWS Lambda](https://aws.amazon.com/lambda) Function URL. 4 | 5 | ### Build and Deploy 6 | 7 | Run the following commands to build and deploy the application to lambda. 8 | 9 | ```bash 10 | sam build 11 | 12 | sam deploy --guided 13 | ``` 14 | When the deployment completes, the Function URL will appear in the output list, which is the entrypoint for accessing 15 | 16 | ### Verify it works 17 | 18 | When you open the Function URL in a browser: 19 | 20 | - Primary product information will be loaded first at part of the initial response 21 | 22 | - Secondary, more personalized details (that might be slower) like recommended products and customer reviews are progressively streamed in. 23 | 24 | 25 | ### Thanks 26 | 27 | Page content and styles are powered by the [Next.js App Router Playground - Streaming with Suspense](https://app-dir.vercel.app/streaming). 28 | 29 | ### License 30 | 31 | This library is licensed under the MIT-0 License. See the LICENSE file. 32 | -------------------------------------------------------------------------------- /ui/product-deal.tsx: -------------------------------------------------------------------------------- 1 | import { ProductCurrencySymbol } from '@/ui/product-currency-symbol'; 2 | import { toUnit, type Dinero } from 'dinero.js'; 3 | 4 | export const ProductDeal = ({ 5 | price: priceRaw, 6 | discount: discountRaw, 7 | }: { 8 | price: Dinero; 9 | discount: { 10 | amount: Dinero; 11 | }; 12 | }) => { 13 | const discount = toUnit(discountRaw.amount); 14 | const price = toUnit(priceRaw); 15 | const percent = Math.round(100 - (discount / price) * 100); 16 | 17 | return ( 18 |
19 |
20 | -{percent}% 21 |
22 |
23 |
24 | 25 |
26 |
27 | {discount} 28 |
29 |
30 |
31 | 32 | {price} 33 |
34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /app/api/reviews/route.ts: -------------------------------------------------------------------------------- 1 | import { Review } from '@/app/api/reviews/review'; 2 | 3 | export async function GET(request: Request) { 4 | const { searchParams } = new URL(request.url); 5 | 6 | // We sometimes artificially delay a reponse for demo purposes. 7 | // Don't do this in real life :) 8 | const delay = searchParams.get('delay'); 9 | if (delay) { 10 | await new Promise((resolve) => setTimeout(resolve, Number(delay))); 11 | } 12 | 13 | return new Response(JSON.stringify(reviews), { 14 | status: 200, 15 | headers: { 16 | 'content-type': 'application/json', 17 | }, 18 | }); 19 | } 20 | 21 | const reviews: Review[] = [ 22 | { 23 | id: '1', 24 | name: 'Nullam Duis', 25 | rating: 4, 26 | text: 'Phasellus efficitur, nisi ut varius ultricies, tortor arcu ullamcorper nisi, eu auctor enim est ut enim. Sed fringilla, nulla ut tincidunt hendrerit, risus tortor laoreet tortor, non mattis arcu elit vel ante.', 27 | }, 28 | { 29 | id: '2', 30 | name: 'Donec Nulla Velit', 31 | rating: 1, 32 | text: 'Nullam fermentum nisl non mattis fringilla!!!!', 33 | }, 34 | { 35 | id: '3', 36 | name: 'J Tempus', 37 | rating: 3, 38 | text: 'Pellentesque faucibus quam eu vehicula pulvinar. Integer cursus fringilla metus.', 39 | }, 40 | ]; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lwa-streaming-response-demo-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@heroicons/react": "1.0.6", 13 | "@tailwindcss/forms": "0.5.3", 14 | "@tailwindcss/line-clamp": "0.4.2", 15 | "@types/node": "18.15.11", 16 | "@types/react": "18.0.33", 17 | "@types/react-dom": "18.0.11", 18 | "autoprefixer": "10.4.14", 19 | "clsx": "1.2.1", 20 | "date-fns": "2.29.3", 21 | "dinero.js": "2.0.0-alpha.8", 22 | "eslint": "8.37.0", 23 | "eslint-config-next": "13.3.0", 24 | "ms": "3.0.0-canary.1", 25 | "next": "14.2.10", 26 | "postcss": "8.4.31", 27 | "react": "18.2.0", 28 | "react-dom": "18.2.0", 29 | "sharp": "^0.32.6", 30 | "tailwindcss": "3.3.1", 31 | "typescript": "5.0.3" 32 | }, 33 | "devDependencies": { 34 | "@tailwindcss/typography": "0.5.9", 35 | "@types/ms": "0.7.31", 36 | "@types/node": "18.15.11", 37 | "@types/react": "18.0.33", 38 | "@types/react-dom": "18.0.11", 39 | "@vercel/git-hooks": "1.0.0", 40 | "autoprefixer": "10.4.13", 41 | "eslint": "8.30.0", 42 | "eslint-config-next": "13.1.0", 43 | "lint-staged": "13.1.0", 44 | "postcss": "8.4.31", 45 | "prettier": "2.8.1", 46 | "prettier-plugin-tailwindcss": "0.2.1", 47 | "tailwindcss": "3.3.1", 48 | "typescript": "5.0.3" 49 | } 50 | } -------------------------------------------------------------------------------- /app/_components/reviews.tsx: -------------------------------------------------------------------------------- 1 | import type { Review } from '@/app/api/reviews/review'; 2 | import { ProductReviewCard } from '@/ui/product-review-card'; 3 | 4 | export async function Reviews({ data }: { data: Promise }) { 5 | const reviews = (await data.then((res) => res.json())) as Review[]; 6 | 7 | return ( 8 |
9 | {reviews.map((review) => { 10 | return ; 11 | })} 12 |
13 | ); 14 | } 15 | 16 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`; 17 | 18 | function Skeleton() { 19 | return ( 20 |
21 |
22 |
23 |
24 |
25 |
26 | ); 27 | } 28 | 29 | export function ReviewsSkeleton() { 30 | return ( 31 |
32 |
33 | 34 |
35 | 36 | 37 |
38 |
39 | ); 40 | } -------------------------------------------------------------------------------- /ui/product-price.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/app/api/products/product"; 2 | import { Dinero, multiply, toUnit } from "dinero.js"; 3 | import { ProductCurrencySymbol } from "./product-currency-symbol"; 4 | import { ProductDeal } from "./product-deal"; 5 | import { ProductLighteningDeal } from "./product-lightening-deal"; 6 | 7 | function isDiscount(obj: any): obj is { percent: number; expires?: number } { 8 | return typeof obj?.percent === 'number'; 9 | } 10 | 11 | function formatDiscount( 12 | price: Dinero, 13 | discountRaw: Product['discount'], 14 | ) { 15 | return isDiscount(discountRaw) 16 | ? { 17 | amount: multiply(price, { 18 | amount: discountRaw.percent, 19 | scale: 2, 20 | }), 21 | expires: discountRaw.expires, 22 | } 23 | : undefined; 24 | } 25 | 26 | export const ProductPrice = ({ 27 | price, 28 | discount: discountRaw 29 | }: { 30 | price: Dinero, 31 | discount: Product['discount'] 32 | }) => { 33 | const discount = formatDiscount(price, discountRaw); 34 | 35 | if (discount) { 36 | if (discount?.expires && typeof discount.expires === 'number') { 37 | return ; 38 | } 39 | return ; 40 | } 41 | 42 | return ( 43 |
44 |
45 | 46 |
47 |
48 | {toUnit(price)} 49 |
50 |
51 | ); 52 | } -------------------------------------------------------------------------------- /app/_components/recommanded-products.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/app/api/products/product"; 2 | import { ProductCard } from "@/ui/product-card"; 3 | 4 | export async function RecommendedProducts({ 5 | path, 6 | data 7 | }: { 8 | path: string, 9 | data: Promise 10 | }) { 11 | 12 | const products = (await data.then((res) => res.json())) as Product[] 13 | 14 | return ( 15 |
16 | {products.map((product) => ( 17 |
18 | 19 |
20 | ))} 21 |
22 | ) 23 | 24 | } 25 | 26 | 27 | const shimmer = `relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent`; 28 | 29 | function ProductSkeleton() { 30 | return ( 31 |
32 |
33 | 34 |
35 |
36 |
37 |
38 |
39 | ); 40 | } 41 | 42 | export function RecommendedProductsSkeleton() { 43 | return ( 44 |
45 |
46 |
47 |
48 |
49 | 50 |
51 | 52 | 53 | 54 | 55 |
56 |
57 | ); 58 | } -------------------------------------------------------------------------------- /ui/product-card.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from "@/app/api/products/product"; 2 | import { DineroSnapshot, dinero } from "dinero.js"; 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { ProductBestSeller } from "./product-best-seller"; 6 | import { ProductEstimatedArrival } from "./product-estimated-arrival"; 7 | import { ProductLowStockWarning } from "./product-low-stock-warning"; 8 | import { ProductPrice } from "./product-price"; 9 | import { ProductRating } from "./product-rating"; 10 | import { ProductUsedPrice } from "./product-used-price"; 11 | 12 | export const ProductCard = ({ 13 | product, 14 | href, 15 | }: { 16 | product: Product; 17 | href: string; 18 | }) => { 19 | const price = dinero(product.price as DineroSnapshot); 20 | return ( 21 | 22 |
23 |
24 | {product.isBestSeller ? ( 25 |
26 | 27 |
28 | ) : null} 29 | {product.name} 38 |
39 | 40 |
41 | {product.name} 42 |
43 | 44 | {product.rating ? : null} 45 | 46 | 47 | 48 | {product.usedPrice ? ( 49 | 50 | ) : null} 51 | 52 | 53 | 54 | {product.stock <= 1 ? ( 55 | 56 | ) : null} 57 |
58 | 59 | ) 60 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors'); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ 6 | './app/**/*.{js,ts,jsx,tsx}', 7 | './pages/**/*.{js,ts,jsx,tsx}', 8 | './ui/**/*.{js,ts,jsx,tsx}', 9 | ], 10 | future: { 11 | hoverOnlyWhenSupported: true, 12 | }, 13 | darkMode: 'class', 14 | theme: { 15 | extend: { 16 | // fontFamily: { 17 | // sans: ['var(--primary-font)'], 18 | // }, 19 | // https://vercel.com/design/color 20 | colors: { 21 | gray: colors.zinc, 22 | 'gray-1000': 'rgb(17,17,19)', 23 | 'gray-1100': 'rgb(10,10,11)', 24 | vercel: { 25 | pink: '#FF0080', 26 | blue: '#0070F3', 27 | cyan: '#50E3C2', 28 | orange: '#F5A623', 29 | violet: '#7928CA', 30 | }, 31 | }, 32 | backgroundImage: ({ theme }) => ({ 33 | 'vc-border-gradient': `radial-gradient(at left top, ${theme( 34 | 'colors.gray.500', 35 | )}, 50px, ${theme('colors.gray.800')} 50%)`, 36 | }), 37 | keyframes: ({ theme }) => ({ 38 | rerender: { 39 | '0%': { 40 | ['border-color']: theme('colors.vercel.pink'), 41 | }, 42 | '40%': { 43 | ['border-color']: theme('colors.vercel.pink'), 44 | }, 45 | }, 46 | highlight: { 47 | '0%': { 48 | background: theme('colors.vercel.pink'), 49 | color: theme('colors.white'), 50 | }, 51 | '40%': { 52 | background: theme('colors.vercel.pink'), 53 | color: theme('colors.white'), 54 | }, 55 | }, 56 | loading: { 57 | '0%': { 58 | opacity: '.2', 59 | }, 60 | '20%': { 61 | opacity: '1', 62 | transform: 'translateX(1px)', 63 | }, 64 | to: { 65 | opacity: '.2', 66 | }, 67 | }, 68 | shimmer: { 69 | '100%': { 70 | transform: 'translateX(100%)', 71 | }, 72 | }, 73 | translateXReset: { 74 | '100%': { 75 | transform: 'translateX(0)', 76 | }, 77 | }, 78 | fadeToTransparent: { 79 | '0%': { 80 | opacity: 1, 81 | }, 82 | '40%': { 83 | opacity: 1, 84 | }, 85 | '100%': { 86 | opacity: 0, 87 | }, 88 | }, 89 | }), 90 | }, 91 | }, 92 | plugins: [ 93 | require('@tailwindcss/typography'), 94 | require('@tailwindcss/forms'), 95 | require('@tailwindcss/line-clamp'), 96 | ], 97 | }; 98 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { getBaseUrl } from "@/lib/getBaseUrl"; 2 | import { Ping } from "@/ui/ping"; 3 | import { Suspense } from "react"; 4 | import { RecommendedProducts, RecommendedProductsSkeleton } from "./_components/recommanded-products"; 5 | import { Reviews, ReviewsSkeleton } from "./_components/reviews"; 6 | import { SingleProduct } from "./_components/single-product"; 7 | 8 | export default async function Page({ params }: { params: { id: string } }) { 9 | return ( 10 |
11 | {/* @ts-expect-error Async Server Component */} 12 | 16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 | Recommended Products for You 27 |
28 |
29 | Based on you preferences and shopping habits 30 |
31 |
32 | }> 33 | {/* @ts-expect-error Async Server Component */} 34 | 43 | 44 |
45 | 46 |
47 |
48 | 49 |
50 |
51 | 52 |
53 |
Customer Reviews
54 | }> 55 | {/* @ts-expect-error Async Server Component */} 56 | 62 | 63 |
64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /app/_components/single-product.tsx: -------------------------------------------------------------------------------- 1 | import type { Product } from "@/app/api/products/product" 2 | import { ProductRating } from "@/ui/product-rating" 3 | import Image from "next/image" 4 | 5 | export const SingleProduct = async ({ data }: { data: Promise }) => { 6 | const product = (await data.then((res) => res.json())) as Product 7 | 8 | return ( 9 |
10 |
11 |
12 | {product.name} 19 |
20 |
21 | {product.name} 28 |
29 |
30 | {product.name} 37 |
38 |
39 | {product.name} 46 |
47 |
48 |
49 |
50 | 51 |
52 |
53 | {product.name} 54 |
55 | 56 | 57 | 58 |
59 |

{product.description}

60 |
61 | 62 |
63 |
64 | ) 65 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | 3 | export const metadata = { 4 | title: 'nextjs streaming demo using suspense fallback' 5 | } 6 | 7 | export default function RootLayout({ 8 | children, 9 | }: { 10 | children: React.ReactNode 11 | }) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 |
27 |
28 |
29 |
    30 |
  • 31 | Primary product information is loaded first as part of the initial 32 | response. 33 |
  • 34 |
  • 35 | Secondary, more personalized details (that might be slower) like 36 | ship date, other recommended products, and customer reviews are 37 | progressively streamed in. 38 |
  • 39 |
40 |
41 |
42 |
43 |
Demo
44 |
45 |
46 | {children} 47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /ui/boundary.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React from 'react'; 3 | 4 | const Label = ({ 5 | children, 6 | animateRerendering, 7 | color, 8 | }: { 9 | children: React.ReactNode; 10 | animateRerendering?: boolean; 11 | color?: 'default' | 'pink' | 'blue' | 'violet' | 'cyan' | 'orange'; 12 | }) => { 13 | return ( 14 |
25 | {children} 26 |
27 | ); 28 | }; 29 | export const Boundary = ({ 30 | children, 31 | labels = ['children'], 32 | size = 'default', 33 | color = 'default', 34 | animateRerendering = true, 35 | }: { 36 | children: React.ReactNode; 37 | labels?: string[]; 38 | size?: 'small' | 'default'; 39 | color?: 'default' | 'pink' | 'blue' | 'violet' | 'cyan' | 'orange'; 40 | animateRerendering?: boolean; 41 | }) => { 42 | return ( 43 |
57 |
66 | {labels.map((label) => { 67 | return ( 68 | 75 | ); 76 | })} 77 |
78 | 79 | {children} 80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /app/api/products/route.ts: -------------------------------------------------------------------------------- 1 | import type { Product } from "@/app/api/products/product"; 2 | 3 | export async function GET(request: Request) { 4 | 5 | const { searchParams } = new URL(request.url); 6 | const delay = searchParams.get('delay'); 7 | if (delay) { 8 | await new Promise((resolve) => setTimeout(resolve, Number(delay))); 9 | } 10 | 11 | const id = searchParams.get('id'); 12 | if (id) { 13 | let product = data.find((product) => product.id === id); 14 | 15 | const fields = searchParams.get('fields'); 16 | if (product && fields) { 17 | product = fields.split(',').reduce((acc, field) => { 18 | // @ts-ignore 19 | acc[field] = product[field]; 20 | 21 | return acc; 22 | }, {} as Product); 23 | } 24 | return new Response(JSON.stringify(product ?? null), { 25 | status: 200, 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }); 30 | } 31 | 32 | const filter = searchParams.get('filter'); 33 | const products = filter 34 | ? data.filter((product) => product.id !== filter) 35 | : data; 36 | 37 | return new Response(JSON.stringify(products), { 38 | status: 200, 39 | headers: { 40 | 'content-type': 'application/json', 41 | }, 42 | }); 43 | } 44 | 45 | const data: Product[] = [ 46 | { 47 | id: '1', 48 | stock: 2, 49 | rating: 5, 50 | name: 'Donec sit elit', 51 | description: 52 | 'Morbi eu ullamcorper urna, a condimentum massa. In fermentum ante non turpis cursus fringilla. Praesent neque eros, gravida vel ante sed, vehicula elementum orci. Sed eu ipsum eget enim mattis mollis.', 53 | price: { 54 | amount: 4200, 55 | currency: { code: 'USD', base: 10, exponent: 2 }, 56 | scale: 2, 57 | }, 58 | isBestSeller: false, 59 | leadTime: 2, 60 | discount: { percent: 90, expires: 2 }, 61 | image: 'https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f', 62 | imageBlur: 63 | 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAqgAwAEAAAAAQAAAAoAAAAA/8AAEQgACgAKAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A+3/HPx10jQPE0+k6ZrPh6TTtKsbi41R7nVUiu7WeMkQxi2H3lk2su4sCrjBHFd54c+InhvxJ4e0vxFa3aRw6pawXSKxG5VnQOAfcA81474z8G+ENU1OeXU9Dsbt/N8zdNbRSHfn72WU/N79a9U03TtPj061jjtYkRIkAARQAAowAMV2Sa7GsIH//2Q==', 64 | }, 65 | { 66 | id: '2', 67 | stock: 5, 68 | rating: 4, 69 | name: 'Fusce commodo porta posuere', 70 | description: 71 | 'Morbi eu ullamcorper urna, a condimentum massa. In fermentum ante non turpis cursus fringilla. Praesent neque eros, gravida vel ante sed, vehicula elementum orci. Sed eu ipsum eget enim mattis mollis.', 72 | price: { 73 | amount: 4600, 74 | currency: { code: 'USD', base: 10, exponent: 2 }, 75 | scale: 2, 76 | }, 77 | isBestSeller: false, 78 | leadTime: 1, 79 | image: 'https://images.unsplash.com/photo-1612547036242-77002603e5aa', 80 | imageBlur: 81 | 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAqgAwAEAAAAAQAAAAoAAAAA/8AAEQgACgAKAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8AwNV+KvwHs7vSNLi8faJoy2VtcQatFLaSz3X2shBGyuUKjy23kgKwY4HStq5j0O4uZZ9Lfz7KR2aCQArviJyjYIyMrg4NXY7Cxddz20bE9SUU/wBKfsQcBQAPav6Gyrh+vQq1pyxMpKTuk+mr0Wr7/gf568TeI2DxlDDUqWXwpumrNpr3tIq7tFa6X67n/9k=', 82 | }, 83 | { 84 | id: '3', 85 | stock: 3, 86 | rating: 3, 87 | name: 'Praesent tincidunt lectus', 88 | description: 89 | 'Morbi eu ullamcorper urna, a condimentum massa. In fermentum ante non turpis cursus fringilla. Praesent neque eros, gravida vel ante sed, vehicula elementum orci. Sed eu ipsum eget enim mattis mollis.', 90 | price: { 91 | amount: 29200, 92 | currency: { code: 'USD', base: 10, exponent: 2 }, 93 | scale: 2, 94 | }, 95 | discount: { percent: 70, expires: 7 }, 96 | isBestSeller: true, 97 | leadTime: 2, 98 | image: 'https://images.unsplash.com/photo-1496889050590-4db81f7fb62a', 99 | imageBlur: 100 | 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAqgAwAEAAAAAQAAAAoAAAAA/8AAEQgACgAKAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A+6vG/wC1t4J+GniLxP4L1Wynub7RrqWOQBJ1DvLbG9AVxC6ECHPO4DdhM7yBX2XpP2iTSrOROFaGMgegKivPPEGnafLrEckttE7yOu4siktgdzjmvTE4RQOAAKDSx//Z', 101 | }, 102 | { 103 | id: '4', 104 | stock: 2, 105 | rating: 5, 106 | name: 'Morbi at viverra turpis', 107 | description: 108 | 'Morbi eu ullamcorper urna, a condimentum massa. In fermentum ante non turpis cursus fringilla. Praesent neque eros, gravida vel ante sed, vehicula elementum orci. Sed eu ipsum eget enim mattis mollis.', 109 | price: { 110 | amount: 21200, 111 | currency: { code: 'USD', base: 10, exponent: 2 }, 112 | scale: 2, 113 | }, 114 | isBestSeller: false, 115 | leadTime: 2, 116 | image: 'https://images.unsplash.com/photo-1488241561087-799714b46586', 117 | imageBlur: 118 | 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAqgAwAEAAAAAQAAAAoAAAAA/8AAEQgACgAKAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8Ay59W0U20sUGmapJf2hZZVR7TyWVbd5hKgd1k2yOohVeu4jHGTXBwa9PcQR3BhaAyqG8uQ/Om4Z2tjjI6HHGa2dVtrad3aeJJCOhZQf51TjACKAMAAV0u99z8bni6E4RUaVmt3fc//9k=', 119 | }, 120 | { 121 | id: '5', 122 | stock: 1, 123 | rating: 4, 124 | name: 'Maecenas interdum', 125 | description: 126 | 'Morbi eu ullamcorper urna, a condimentum massa. In fermentum ante non turpis cursus fringilla. Praesent neque eros, gravida vel ante sed, vehicula elementum orci. Sed eu ipsum eget enim mattis mollis.', 127 | price: { 128 | amount: 28700, 129 | currency: { code: 'USD', base: 10, exponent: 2 }, 130 | scale: 2, 131 | }, 132 | isBestSeller: false, 133 | leadTime: 4, 134 | image: 'https://images.unsplash.com/photo-1630936703945-7651b922d655', 135 | imageBlur: 136 | 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAqgAwAEAAAAAQAAAAoAAAAA/8AAEQgACgAKAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwUDAwMFBgUFBQUGCAYGBgYGCAoICAgICAgKCgoKCgoKCgwMDAwMDA4ODg4ODw8PDw8PDw8PD//bAEMBAgICBAQEBwQEBxALCQsQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEP/dAAQAAf/aAAwDAQACEQMRAD8A9P8Aj/qni3w58aNel0PV9UENm3lraDUfLtpJJLeOMKlqwXYELGRX3/M3zD3/AE5tPDB+yQ7pFc7FySeTx1NeX+PfhF8J/FPjI+IfE3grRNX1UPC4u7zTba4uN0f3D5skbPlf4TnjtXvcdrbLGoEKAADjaKpQa3Z1Od9j/9k=', 137 | }, 138 | ]; 139 | --------------------------------------------------------------------------------