-
Notifications
You must be signed in to change notification settings - Fork 0
Add e-commerce product page with SSR, Client, and RSC versions #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4460bdb
f9fe932
429fd7c
1c246b5
4bd52d7
1f56465
396aa3d
6f13540
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| module Api | ||
| class ProductsController < ApplicationController | ||
| skip_forgery_protection | ||
|
|
||
| # GET /api/products/:id/reviews | ||
| def reviews | ||
| product = Product.find(params[:id]) | ||
| reviews = product.top_reviews(10) | ||
|
|
||
| render json: { | ||
| reviews: reviews.map { |r| serialize_review(r) }, | ||
| timestamp: Time.current.iso8601 | ||
| } | ||
| end | ||
|
|
||
| # GET /api/products/:id/review_stats | ||
| def review_stats | ||
| product = Product.find(params[:id]) | ||
|
|
||
| render json: { | ||
| **product.review_stats, | ||
| timestamp: Time.current.iso8601 | ||
| } | ||
| end | ||
|
|
||
| # GET /api/products/:id/related_products | ||
| def related_products | ||
| product = Product.find(params[:id]) | ||
| related = product.related_products(4) | ||
|
|
||
| render json: { | ||
| products: related.map { |p| serialize_product_card(p) }, | ||
| timestamp: Time.current.iso8601 | ||
| } | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def serialize_review(review) | ||
| { | ||
| id: review.id, | ||
| rating: review.rating, | ||
| title: review.title, | ||
| comment: review.comment, | ||
| reviewer_name: review.reviewer_name, | ||
| verified_purchase: review.verified_purchase, | ||
| helpful_count: review.helpful_count, | ||
| created_at: review.created_at.iso8601 | ||
| } | ||
| end | ||
|
|
||
| def serialize_product_card(product) | ||
| { | ||
| id: product.id, | ||
| name: product.name, | ||
| price: product.price.to_f, | ||
| original_price: product.original_price&.to_f, | ||
| category: product.category, | ||
| brand: product.brand, | ||
| images: product.images, | ||
| average_rating: product.average_rating.to_f, | ||
| review_count: product.review_count, | ||
| in_stock: product.in_stock, | ||
| discount_percentage: product.discount_percentage | ||
| } | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| class ProductsController < ApplicationController | ||
| include ReactOnRailsPro::RSCPayloadRenderer | ||
| include ReactOnRailsPro::AsyncRendering | ||
|
|
||
| enable_async_react_rendering only: [:show_rsc] | ||
|
|
||
| # V1: Full Server SSR — fetch ALL data, return complete page | ||
| # All data must be ready before ANY HTML is sent to the browser. | ||
| def show_ssr | ||
| product = find_product | ||
|
|
||
| # Sequential queries — each one blocks the response | ||
| reviews = product.top_reviews(10) | ||
| review_stats = product.review_stats | ||
| related = product.related_products(4) | ||
|
|
||
| @product_data = serialize_product(product) | ||
| @reviews_data = reviews.map { |r| serialize_review(r) } | ||
| @review_stats_data = review_stats | ||
| @related_products_data = related.map { |p| serialize_product_card(p) } | ||
| end | ||
|
|
||
| # V2: Client Components — send basic product data, client fetches the rest | ||
| def show_client | ||
| @product_data = serialize_product(find_product) | ||
| end | ||
|
|
||
| # V3: RSC Streaming — shell streams immediately, heavy data streams as it resolves | ||
| def show_rsc | ||
| @product = find_product | ||
| # Exclude description/features/specs from initial props — they stream via product_details | ||
| # async prop to keep the initial shell small and prioritize LCP. | ||
| @product_data = serialize_product(@product).except(:description, :features, :specs) | ||
| stream_view_containing_react_components(template: "products/show_rsc") | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def hero_image_url | ||
| @product_data&.dig(:images, 0, "url") || @product_data&.dig(:images, 0, :url) | ||
| end | ||
| helper_method :hero_image_url | ||
|
|
||
| def find_product | ||
| if params[:id] | ||
| Product.find(params[:id]) | ||
| else | ||
| Product.first! | ||
| end | ||
| end | ||
|
|
||
| def serialize_product(product) | ||
| { | ||
| id: product.id, | ||
| name: product.name, | ||
| description: product.description, | ||
| price: product.price.to_f, | ||
| original_price: product.original_price&.to_f, | ||
| category: product.category, | ||
| brand: product.brand, | ||
| sku: product.sku, | ||
| images: product.images, | ||
| specs: product.specs, | ||
| features: product.features, | ||
| average_rating: product.average_rating.to_f, | ||
| review_count: product.review_count, | ||
| stock_quantity: product.stock_quantity, | ||
| in_stock: product.in_stock, | ||
| discount_percentage: product.discount_percentage | ||
| } | ||
| end | ||
|
|
||
| def serialize_review(review) | ||
| { | ||
| id: review.id, | ||
| rating: review.rating, | ||
| title: review.title, | ||
| comment: review.comment, | ||
| reviewer_name: review.reviewer_name, | ||
| verified_purchase: review.verified_purchase, | ||
| helpful_count: review.helpful_count, | ||
| created_at: review.created_at.iso8601 | ||
| } | ||
| end | ||
|
|
||
| def serialize_product_card(product) | ||
| { | ||
| id: product.id, | ||
| name: product.name, | ||
| price: product.price.to_f, | ||
| original_price: product.original_price&.to_f, | ||
| category: product.category, | ||
| brand: product.brand, | ||
| images: product.images, | ||
| average_rating: product.average_rating.to_f, | ||
| review_count: product.review_count, | ||
| in_stock: product.in_stock, | ||
| discount_percentage: product.discount_percentage | ||
| } | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| 'use client'; | ||
|
|
||
| import React, { useState, useCallback } from 'react'; | ||
|
|
||
| interface Props { | ||
| price: number; | ||
| inStock: boolean; | ||
| stockQuantity: number; | ||
| } | ||
|
|
||
| export function AddToCartSection({ price, inStock, stockQuantity }: Props) { | ||
| const [quantity, setQuantity] = useState(1); | ||
| const [addedToCart, setAddedToCart] = useState(false); | ||
|
|
||
| const handleDecrement = useCallback(() => { | ||
| setQuantity((q) => Math.max(1, q - 1)); | ||
| }, []); | ||
|
|
||
| const handleIncrement = useCallback(() => { | ||
| setQuantity((q) => Math.min(stockQuantity, q + 1)); | ||
| }, [stockQuantity]); | ||
|
|
||
| const handleAddToCart = useCallback(() => { | ||
| setAddedToCart(true); | ||
| setTimeout(() => setAddedToCart(false), 2000); | ||
| }, []); | ||
|
Comment on lines
+23
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential memory leak: If the component unmounts before the 2-second timeout completes, 🛠️ Proposed fix using useEffect cleanup+import React, { useState, useCallback, useEffect, useRef } from 'react';
// Inside the component:
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
const handleAddToCart = useCallback(() => {
setAddedToCart(true);
- setTimeout(() => setAddedToCart(false), 2000);
+ timeoutRef.current = setTimeout(() => setAddedToCart(false), 2000);
}, []);🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div className="space-y-4"> | ||
| {/* Quantity selector */} | ||
| <div className="flex items-center gap-4"> | ||
| <span className="text-sm font-medium text-gray-700">Quantity</span> | ||
| <div className="flex items-center border border-gray-300 rounded-lg"> | ||
| <button | ||
| onClick={handleDecrement} | ||
| disabled={quantity <= 1} | ||
| className="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-l-lg" | ||
| aria-label="Decrease quantity" | ||
| > | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" d="M5 12h14" /> | ||
| </svg> | ||
| </button> | ||
| <span className="w-12 text-center text-sm font-medium tabular-nums">{quantity}</span> | ||
| <button | ||
| onClick={handleIncrement} | ||
| disabled={quantity >= stockQuantity} | ||
| className="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-r-lg" | ||
| aria-label="Increase quantity" | ||
| > | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" d="M12 5v14m-7-7h14" /> | ||
| </svg> | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Add to Cart & Buy Now buttons */} | ||
| <div className="flex gap-3"> | ||
| <button | ||
| onClick={handleAddToCart} | ||
| disabled={!inStock} | ||
| className={`flex-1 py-3.5 px-6 rounded-xl text-sm font-semibold transition-all ${ | ||
| addedToCart | ||
| ? 'bg-green-600 text-white' | ||
| : inStock | ||
| ? 'bg-indigo-600 text-white hover:bg-indigo-700 active:scale-[0.98]' | ||
| : 'bg-gray-200 text-gray-500 cursor-not-allowed' | ||
| }`} | ||
| > | ||
| {addedToCart ? ( | ||
| <span className="flex items-center justify-center gap-2"> | ||
| <svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> | ||
| </svg> | ||
| Added to Cart | ||
| </span> | ||
| ) : ( | ||
| `Add to Cart — $${(price * quantity).toFixed(2)}` | ||
| )} | ||
| </button> | ||
| <button | ||
| disabled={!inStock} | ||
| className="py-3.5 px-6 rounded-xl text-sm font-semibold border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 active:scale-[0.98] transition-all disabled:opacity-30 disabled:cursor-not-allowed" | ||
| > | ||
| Buy Now | ||
| </button> | ||
| </div> | ||
|
|
||
| {/* Stock & shipping info */} | ||
| <div className="flex flex-col gap-2 pt-2"> | ||
| {inStock ? ( | ||
| <div className="flex items-center gap-2 text-sm text-green-700"> | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> | ||
| </svg> | ||
| In Stock ({stockQuantity} available) | ||
| </div> | ||
| ) : ( | ||
| <div className="flex items-center gap-2 text-sm text-red-600"> | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> | ||
| </svg> | ||
| Out of Stock | ||
| </div> | ||
| )} | ||
| <div className="flex items-center gap-2 text-sm text-gray-600"> | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /> | ||
| </svg> | ||
| Free shipping on orders over $50 | ||
| </div> | ||
| <div className="flex items-center gap-2 text-sm text-gray-600"> | ||
| <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"> | ||
| <path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||
| </svg> | ||
| 30-day hassle-free returns | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| 'use client'; | ||
|
|
||
| // Client-side fetcher for V2 (Client Components version). | ||
| // Fetches reviews, review stats, and related products via API calls. | ||
| // All libraries (date-fns, marked, highlight.js) are loaded client-side. | ||
|
|
||
| import React, { useState, useEffect } from 'react'; | ||
| import { Product, ProductReview, ReviewStats, ProductCard } from '../../types/product'; | ||
| import { ProductDescription } from './ProductDescription'; | ||
| import { ProductFeatures } from './ProductFeatures'; | ||
| import { ProductSpecs } from './ProductSpecs'; | ||
| import { ReviewDistributionChart } from './ReviewDistributionChart'; | ||
| import { ReviewsList } from './ReviewsList'; | ||
| import { RelatedProducts } from './RelatedProducts'; | ||
| import { ReviewStatsSkeleton, ReviewsSkeleton, RelatedProductsSkeleton } from './ProductSkeletons'; | ||
|
|
||
| interface Props { | ||
| product: Product; | ||
| } | ||
|
|
||
| export default function AsyncProductContent({ product }: Props) { | ||
| const [reviewStats, setReviewStats] = useState<ReviewStats | null>(null); | ||
| const [reviews, setReviews] = useState<ProductReview[] | null>(null); | ||
| const [relatedProducts, setRelatedProducts] = useState<ProductCard[] | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| const controller = new AbortController(); | ||
| const opts = { signal: controller.signal }; | ||
|
|
||
| fetch(`/api/products/${product.id}/review_stats`, opts) | ||
| .then((r) => r.json()) | ||
| .then(setReviewStats) | ||
| .catch(() => {}); | ||
|
|
||
| fetch(`/api/products/${product.id}/reviews`, opts) | ||
| .then((r) => r.json()) | ||
| .then((data) => setReviews(data.reviews)) | ||
| .catch(() => {}); | ||
|
|
||
| fetch(`/api/products/${product.id}/related_products`, opts) | ||
| .then((r) => r.json()) | ||
| .then((data) => setRelatedProducts(data.products)) | ||
| .catch(() => {}); | ||
|
Comment on lines
+30
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Silent error handling leaves users with infinite loading state. The empty Additionally, there's no 🐛 Proposed fix with error handling+ const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
const opts = { signal: controller.signal };
+ const handleFetch = async <T,>(url: string, setter: (data: T) => void, transform?: (data: any) => T) => {
+ try {
+ const r = await fetch(url, opts);
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const data = await r.json();
+ setter(transform ? transform(data) : data);
+ } catch (e) {
+ if (e instanceof Error && e.name !== 'AbortError') {
+ setError('Failed to load some content');
+ }
+ }
+ };
- fetch(`/api/products/${product.id}/review_stats`, opts)
- .then((r) => r.json())
- .then(setReviewStats)
- .catch(() => {});
+ handleFetch(`/api/products/${product.id}/review_stats`, setReviewStats);
- fetch(`/api/products/${product.id}/reviews`, opts)
- .then((r) => r.json())
- .then((data) => setReviews(data.reviews))
- .catch(() => {});
+ handleFetch(`/api/products/${product.id}/reviews`, setReviews, (d) => d.reviews);
- fetch(`/api/products/${product.id}/related_products`, opts)
- .then((r) => r.json())
- .then((data) => setRelatedProducts(data.products))
- .catch(() => {});
+ handleFetch(`/api/products/${product.id}/related_products`, setRelatedProducts, (d) => d.products);
return () => controller.abort();
}, [product.id]);🤖 Prompt for AI Agents |
||
|
|
||
| return () => controller.abort(); | ||
| }, [product.id]); | ||
|
|
||
| return ( | ||
| <> | ||
| {/* Product description (uses marked + highlight.js) */} | ||
| <ProductDescription description={product.description} /> | ||
|
|
||
| {/* Features */} | ||
| <ProductFeatures features={product.features} /> | ||
|
|
||
| {/* Specifications */} | ||
| <ProductSpecs specs={product.specs} /> | ||
|
|
||
| {/* Reviews section */} | ||
| <section className="border-t border-gray-200 pt-8 mt-8"> | ||
| <h2 className="text-xl font-bold text-gray-900 mb-6">Customer Reviews</h2> | ||
| {reviewStats ? ( | ||
| <ReviewDistributionChart | ||
| distribution={reviewStats.distribution} | ||
| averageRating={reviewStats.average_rating} | ||
| totalReviews={reviewStats.total_reviews} | ||
| /> | ||
| ) : ( | ||
| <ReviewStatsSkeleton /> | ||
| )} | ||
| <div className="mt-8"> | ||
| {reviews ? <ReviewsList reviews={reviews} /> : <ReviewsSkeleton />} | ||
| </div> | ||
| </section> | ||
|
|
||
| {/* Related products */} | ||
| {relatedProducts ? ( | ||
| <RelatedProducts products={relatedProducts} /> | ||
| ) : ( | ||
| <RelatedProductsSkeleton /> | ||
| )} | ||
| </> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: shakacode/react-server-components-marketplace-demo
Length of output: 324
🏁 Script executed:
Repository: shakacode/react-server-components-marketplace-demo
Length of output: 1386
🏁 Script executed:
Repository: shakacode/react-server-components-marketplace-demo
Length of output: 1744
Potential N+1 query in related_products serialization.
The
related_productsmethod in the Product model doesn't eager-load the images association. Sinceserialize_product_cardaccessesproduct.imagesfor each product, this will trigger a separate database query for each of the 4 related products. Add.includes(:images)to the query in the model method to preload associations:🤖 Prompt for AI Agents