Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

CLAUDE.md
215 changes: 215 additions & 0 deletions Jeff_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
A big thank you to the MeUndies team for the opportunity to work on this code challenge and showcase my approach and skillset. I have provided notes on my approach, trade-offs between approaches, and features I would include or code logic I would refactor if given more time.

Jeff


# MeUndies Product Search Challenge

A modern product listing and search application built with Next.js 15, React 19, TypeScript, and Tailwind CSS, utilizing the Shopify Storefront API and GraphQL for comprehensive product management.

## Project Overview

This application is a product listing search interface that connects to Shopify's Storefront API to provide a seamless product discovery experience. The project demonstrates modern web development practices including server-side rendering, advanced state management, and optimized user experience patterns.

**Tech Stack:**
- Next.js 15 with App Router
- React 19
- TypeScript
- Tailwind CSS
- Shopify Storefront API (GraphQL)
- Custom hooks for state management

## Search System Architecture

### Search Query Handling

Search input is translated directly into Shopify’s Storefront API `query` parameter.
This means the application leverages Shopify’s built-in search capabilities for:

- **Keyword Matching**: The `query` string is passed directly into `products(query: "value")`.
- **Relevance Scoring**: Shopify handles tokenization and ranking of results.
- **Fuzzy Matching**: Near matches (e.g., plurals, partial terms) are resolved by Shopify’s engine.

Search input is passed directly into Shopify’s Storefront API via the $query variable. This leverages Shopify’s built-in search functionality for keyword matching, relevance scoring, and pagination. **See a brief code snippet below**:

<pre>
export const SEARCH_PRODUCTS_QUERY = `
query SearchProducts(
$query: String
$first: Int
$after: String
$before: String
) {
products(
query: $query
first: $first
after: $after
before: $before
sortKey: RELEVANCE
) {
edges {
node {
...ProductFragment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
${PRODUCT_FRAGMENT}
`;

</pre>

### URL Parameter-Based Query Control

The application uses URL parameters (`q` for query, `page` for pagination) to control search logic, providing several key benefits:

1. **Shareability**: Users can bookmark and share URLs with specific search queries, enabling collaborative product discovery
2. **Server Caching**: Common search queries can be cached at the server level, dramatically improving response times for frequently searched terms
3. **SEO Benefits**: Search queries embedded in URLs are indexable by search engines, improving discoverability
4. **Direct Navigation**: Users can navigate directly to search results without going through the homepage

*Implementation: `app/page.tsx:13-15`, `components/SearchSection/SearchSection.tsx:27-44`*

### Custom useSearch Hook

A dedicated `useSearch` hook was created to manage search state instead of using React Context API for several strategic reasons:

1. **Performance Optimization**: Avoids unnecessary re-renders across unrelated components that don't need search state
2. **Scope Isolation**: Search state only affects search-related components, maintaining clean component boundaries
3. **Implementation Simplicity**: Direct state management without the complexity of provider/consumer patterns
4. **Reusability**: The hook can be easily imported and reused in different contexts without provider setup

*Implementation: `hooks/useSearch.ts:14-46`*

## Pagination System

### Page-Based Pagination Design Choice

The application implements a page-based pagination system, chosen to demonstrate a more complicated approach than infinite scroll (back and forward movement through product collection) and its familiarity to customers when browsing product collections:

1. **User Experience**: Allows users to jump to specific pages and naturally peruse product collections
2. **Collection Browsing**: Better suited for product discovery and exploration patterns typical in e-commerce
3. **Familiar Patterns**: Aligns with user expectations from traditional e-commerce experiences

### Implementation Details

The pagination system uses `useState` to handle logic between renders, managing complex state transitions:

- **Cursor-Based Backend**: Utilizes Shopify's cursor-based pagination system while presenting page-based interface
- **State Management**: `useProducts` hook manages pagination state with `useState` for optimal re-render control
- **Search Limitations**: Backward pagination is disabled for search queries due to Shopify API constraints

*Implementation: `hooks/useProducts.ts:92-130`, `components/Pagination/Pagination.tsx:12-74`*

## Shopify/GraphQL Integration

### Development Store Setup

A Shopify development store was created using a combination of store setup and Simple Sample Data to provide realistic testing scenarios:

- **Mock Products**: Comprehensive mix of realistic product data including variants, pricing, and high-quality images
- **GraphQL Storefront API**: Full access to Shopify's robust product catalog structure
- **Data Variety**: Products include multiple categories, price ranges, and inventory states for thorough testing

### useProducts Hook Architecture

The `useProducts` custom hook was chosen over React Context API for product state management:

1. **Component Isolation**: Only product-related components subscribe to product state changes
2. **Performance Optimization**: Prevents unnecessary re-renders in unrelated UI components
3. **State Locality**: Product data remains close to where it's consumed, improving maintainability
4. **Flexible Integration**: Easy to integrate with different components without provider hierarchy constraints

*Implementation: `hooks/useProducts.ts:34-184`, `lib/queries.ts:68-134`*

## SSR & Server Actions

### Performance Optimization Through Server-Side Rendering

The initial load of the first 20 products is performed on the server, which dramatically outperformed client-side rendering approaches:

1. **Data Availability**: Product data is waiting when the page loads, versus needing to be fetched after the document has loaded
2. **Elimination of Loading Waterfalls**: Removes the sequential loading pattern typical in client-side rendered applications
3. **Improved Core Web Vitals**: Reduces Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS)
4. **Enhanced User Experience**: Users see meaningful content immediately rather than loading states

### Caching Strategy

The application implements an in-memory caching system for API responses:
- **Cache Duration**: 5-minute TTL for optimal balance between freshness and performance
- **Cache Keys**: Generated from query parameters to ensure accurate cache hits
- **Production Considerations**: Current in-memory cache should be replaced with Redis or similar distributed cache in production environments

*Implementation: `lib/actions.ts:36-110`, `app/page.tsx:17-44`*

### Server Actions vs. API Routes

During development, I implemented **both an API route and a Server Action** to handle Shopify product queries. While both approaches worked, I ultimately chose **Server Actions** for their performance benefits:

- **Direct Execution**: Server Actions skip the extra HTTP hop required by API routes (`client → API route → Shopify`), leading to lower latency.
- **Seamless SSR**: They integrate directly with Next.js server-side rendering, ensuring products are available immediately on first load.
- **Simpler Caching**: Server Actions work natively with Next.js caching primitives (`revalidateTag`, `revalidatePath`).
- **Reduced Boilerplate**: Less code to maintain compared to API routes.

I left the **API route implementation** in the codebase as a reference (`app/api/products/route.ts`), but opted for Server Actions in production paths due to the measurable performance boost.

## Product Modal

### Enhanced Product Detail Experience

A comprehensive Product Modal provides detailed product information without leaving the current page:

- **Image Gallery**: Multiple product images with thumbnail navigation and full-size viewing
- **Product Information**: Title, description, pricing, variants, and availability status
- **Placeholder Add to Cart**: CTA button ready for e-commerce integration
- **Accessibility**: Keyboard navigation support (ESC key to close) and proper focus management

*Implementation: `components/ProductModal/ProductModal.tsx:14-226`*


## Future Enhancements

If additional development time were available, the following features would provide significant value:

1. **Advanced Filtering**: Product filtering by type, tags, price range, and availability. This is a highly effective feature that allows customers to find and locate products.
2. **Sorting Options**: Multiple sorting criteria including price (low to high, high to low), popularity, newest arrivals, and customer ratings. This, like advanced filtering, is also a highly sought after feature that should be implemented to help the customer locate products.
3. **Wishlist/Favorites**: User ability to save products for later consideration with persistent storage
4. **Persisting Recently Searched Terms**: A simple and convenient feature is saving recently searched terms in localStorage for future customer reference.
5. **Enhancing Search Behavior**: Shopify’s built-in products(query:) provides a good starting point, but has limitations in partial matching. An example I found is when searching for "board", Shopify may not return "snowboard". To enhance search relevance, I would consider these areas:
- Supplement Product descriptions, Types, and Tags to include frequently searched terms.
- Synonym Handling on the client side. Store frequently used terms and reference a search term table that Shopify's query can better understand. **See code example below:**
<pre>
// Brief Pseudocode example
const synonyms = {
board: ["snowboard", "skateboard", "surfboard"],
tee: ["t-shirt", "shirt"],
};
const expandedQuery = [query, ...(synonyms[query] || [])].join(" OR ");
</pre>


## Code Refactoring

If given more time, I would perform the following code enhancements:

1. useProducts Hook Complexity (hooks/useProducts.ts:34-184)

Problem: The useProducts hook has grown too complex with multiple responsibilities:
- This custom hook is the heart of product state, handling product state, pagination, search, and cursor management. It currently handles too much logic and can be cumbersome to modify and test.
- Effect coordination between search queries and pagination. This area will grow when product filtering and sorting is introduced.

Refactor Approach:
- Extract a Pagination Manager: Create a separate usePagination hook to handle cursor-based pagination logic.
- Separate Search vs Browse Logic: Split into useProductSearch and useProductBrowse hooks
- Browse mode can be composed of a simpler custom hook, containing fetching products and pagination.
- Search mode holds more logic that should be extracted to clean up the codebase and also allow isolated testing for search related features (debounce, Save to recent)
- Custom Hook Composition: Compose smaller, focused hooks rather than one monolithic hook

Benefits: Better testability, easier debugging, clearer separation of concerns, and more maintainable code.



33 changes: 33 additions & 0 deletions app/api/products/[handle]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { shopifyFetch } from '@/lib/shopify';
import { GET_PRODUCT_BY_HANDLE_QUERY } from '@/lib/queries';
import { ShopifyProduct } from '@/types/shopify';

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ handle: string }> }
) {
try {
const { handle } = await params;

const data = await shopifyFetch<{ productByHandle: ShopifyProduct }>({
query: GET_PRODUCT_BY_HANDLE_QUERY,
variables: { handle },
});

if (!data.productByHandle) {
return NextResponse.json(
{ error: 'Product not found' },
{ status: 404 }
);
}

return NextResponse.json(data.productByHandle);
} catch (error) {
console.error('Product fetch error:', error);
return NextResponse.json(
{ error: 'Failed to fetch product' },
{ status: 500 }
);
}
}
53 changes: 53 additions & 0 deletions app/api/products/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { shopifyFetch } from '@/lib/shopify';
import { SEARCH_PRODUCTS_QUERY, GET_PRODUCTS_QUERY } from '@/lib/queries';
import { ShopifyProductsResponse, ProductSearchParams } from '@/types/shopify';

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);

const params: ProductSearchParams = {
query: searchParams.get('query') || undefined,
first: searchParams.get('first') ? parseInt(searchParams.get('first')!) : undefined,
after: searchParams.get('after') || undefined,
last: searchParams.get('last') ? parseInt(searchParams.get('last')!) : undefined,
before: searchParams.get('before') || undefined,
sortKey: (searchParams.get('sortKey') as ProductSearchParams['sortKey']) || 'RELEVANCE',
reverse: searchParams.get('reverse') === 'true',
};

// Set default pagination - use 'first: 20' for initial load and forward pagination
if (!params.first && !params.last) {
params.first = 20;
}

const query = params.query ? SEARCH_PRODUCTS_QUERY : GET_PRODUCTS_QUERY;
const variables = params.query
? params
: {
first: params.first,
after: params.after,
last: params.last,
before: params.before,
sortKey: params.sortKey,
reverse: params.reverse
};

const data = await shopifyFetch<ShopifyProductsResponse>({
query,
variables: variables as Record<string, unknown>,
});

return NextResponse.json({
products: data.products.edges.map(edge => edge.node),
pageInfo: data.products.pageInfo,
});
} catch (error) {
console.error('Product search error:', error);
return NextResponse.json(
{ error: 'Failed to fetch products' },
{ status: 500 }
);
}
}
23 changes: 10 additions & 13 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import "tailwindcss/preflight";
@import "tailwindcss"; /* judgmentally pull in full tailwind package */

:root {
--background: #ffffff;
Expand All @@ -8,20 +8,17 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primaryA: #d23f5a;
--color-primaryB: #a5b15e;
--color-secondaryA: #9c6d26;
--color-secondaryB: #e4d8c1;
--color-secondaryC: #fffbf1;
--color-secondaryD: #d4bea5;
--color-tertiaryA: #d9ff6e;
--color-tertiaryB: #fefefe;
--color-tertiaryC: #cbf;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}

@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}

body {
background: var(--background);
color: var(--foreground);
}

@tailwind utilities;
6 changes: 3 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});

export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Shopify Product Explorer",
description: "Search and discover products with our intuitive product explorer",
};

export default function RootLayout({
Expand All @@ -25,7 +25,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-secondaryC`}
>
{children}
</body>
Expand Down
Loading