Skip to content
Merged
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
145 changes: 145 additions & 0 deletions STRUCTURED_DATA_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Structured Data Implementation for Filter Controls

## Overview

This document describes the implementation of Structured Data (JSON-LD) for Filter Controls in the TeachLink application. This implementation improves SEO and accessibility by providing machine-readable metadata about filter options.

## Implementation Details

### Files Created

1. **`src/utils/structuredDataUtils.ts`**

- Utility functions for generating JSON-LD structured data
- Functions:
- `generateFilterStructuredData()`: Generates complete filter control structured data
- `generateBreadcrumbStructuredData()`: Generates breadcrumb navigation structured data
- `generateFilterGroupStructuredData()`: Generates individual filter group structured data
- `validateStructuredData()`: Validates JSON-LD structure and schema.org compliance

2. **`src/components/seo/StructuredDataScript.tsx`**

- React component to render JSON-LD script tags
- Validates JSON before rendering
- Uses `type="application/ld+json"` for proper schema.org recognition

3. **`src/utils/__tests__/structuredDataUtils.test.ts`**
- Comprehensive unit tests for structured data utilities
- Tests for generation, validation, and edge cases

### Files Modified

1. **`src/components/search/FilterSidebar.tsx`**

- Added structured data generation for search filters
- Includes: difficulty, duration, price, topics, instructor, node affinity
- Integrated `StructuredDataScript` component

2. **`src/components/dashboard/DashboardFilters.tsx`**

- Added structured data for dashboard analytics filters
- Includes: time range, aggregation, metric, categories
- Integrated `StructuredDataScript` component

3. **`src/components/search/FacetedFilterSystem.tsx`**
- Added structured data for faceted search filters
- Includes: content type, topics, difficulty, price, rating
- Integrated `StructuredDataScript` component

## Schema.org Compliance

The implementation follows schema.org specifications:

- Uses `@context: https://schema.org`
- Implements `FilterControls` type for main filter groups
- Uses `ItemList` for filter options
- Uses `PropertyValueSpecification` for individual filter groups
- Uses `BreadcrumbList` for navigation breadcrumbs

## Accessibility Considerations

### ARIA Labels

- All existing ARIA labels are preserved
- Structured data provides additional semantic information
- Screen readers can access filter metadata through JSON-LD

### Keyboard Navigation

- No changes to keyboard navigation
- Existing keyboard shortcuts and focus management remain intact

### Screen Reader Support

- JSON-LD provides machine-readable descriptions
- Filter options include descriptive text in structured data
- Helps assistive technologies understand filter semantics

## Security Considerations

### XSS Prevention

- `StructuredDataScript` component uses `dangerouslySetInnerHTML` only after validation
- JSON parsing validates structure before rendering
- No user input is directly injected without sanitization

### Data Validation

- `validateStructuredData()` function ensures:
- Valid JSON format
- Required fields present (@context, @type)
- Schema.org context compliance
- Invalid structured data is not rendered to prevent console errors

### Performance Impact

- Structured data generation uses `useMemo` to avoid unnecessary recalculations
- Minimal performance overhead (JSON serialization)
- Only renders when filter values change

## Testing

### Unit Tests

- Comprehensive test coverage for all utility functions
- Tests for:
- JSON-LD generation
- Schema.org compliance
- Validation logic
- Edge cases (invalid JSON, missing fields)

### Integration Testing

- Structured data is integrated into all three filter components
- Each component generates appropriate metadata for its filters
- No regression in existing functionality

## Browser Compatibility

- JSON-LD is supported by all modern browsers
- Graceful degradation for older browsers (script tag simply not rendered)
- No impact on core functionality if structured data fails to load

## SEO Benefits

- Search engines can understand filter structure
- Rich snippets potential for filter pages
- Improved indexing of filterable content
- Better semantic understanding of UI controls

## Future Enhancements

Potential improvements:

1. Dynamic structured data based on actual filter results
2. Integration with Google Rich Results testing
3. Add more schema.org types as needed
4. Server-side rendering for initial structured data
5. Internationalization support for structured data descriptions

## Maintenance Notes

- When adding new filters, update the corresponding structured data generation
- Keep structured data in sync with UI changes
- Run tests after modifying filter components
- Validate JSON-LD using Google's Structured Data Testing Tool
12 changes: 6 additions & 6 deletions src/app/api/tipping/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ interface TipApiResponse {
txHash: string;
recipientId: string;
amount: number;
notarizationId: string;
notarizationProof: string;
notarizedAt: string;
id: string;
proof: string;
recordedAt: string;
}

function createTransactionHash(): string {
Expand Down Expand Up @@ -44,9 +44,9 @@ export async function POST(request: NextRequest) {
txHash,
recipientId: body.recipientId,
amount: body.amount,
notarizationId: record.id,
notarizationProof: record.proof,
notarizedAt: record.recordedAt,
id: record.id,
proof: record.proof,
recordedAt: record.recordedAt,
};

return NextResponse.json(response, { status: 201 });
Expand Down
84 changes: 84 additions & 0 deletions src/app/api/v1/[...route]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { GET, POST } from '../route';

const mockFetch = vi.fn();

describe('API v1 catch-all proxy route', () => {
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
});

afterEach(() => {
vi.restoreAllMocks();
mockFetch.mockReset();
});

it('forwards GET requests to the original /api path', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

const request = new Request('https://example.com/api/v1/courses', {
method: 'GET',
headers: { 'X-Test': 'true' },
});

const response = await GET(request as any, { params: { route: ['courses'] } });

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api/courses',
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers),
}),
);

const calledHeaders = mockFetch.mock.calls[0][1].headers as Headers;
expect(calledHeaders.get('x-internal-api-request')).toBe('true');
expect(calledHeaders.get('x-api-version')).toBe('v1');
expect(response.headers.get('x-api-version')).toBe('v1');
expect(await response.json()).toEqual({ success: true });
});

it('forwards POST requests with the request body to the original /api path', async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ status: 'posted' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
);

const body = { recipientId: 'user-123', amount: 0.05 };
const request = new Request('https://example.com/api/v1/tips', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

const response = await POST(request as any, { params: { route: ['tips'] } });

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://example.com/api/tips',
expect.objectContaining({
method: 'POST',
headers: expect.any(Headers),
}),
);

const calledHeaders = mockFetch.mock.calls[0][1].headers as Headers;
expect(calledHeaders.get('x-internal-api-request')).toBe('true');
expect(calledHeaders.get('x-api-version')).toBe('v1');

const requestBody = mockFetch.mock.calls[0][1].body as ArrayBuffer;
const decodedBody = JSON.parse(new TextDecoder().decode(requestBody));
expect(decodedBody).toEqual(body);

expect(response.status).toBe(201);
expect(await response.json()).toEqual({ status: 'posted' });
});
});
72 changes: 72 additions & 0 deletions src/app/api/v1/[...route]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { API_VERSION_HEADER, INTERNAL_API_REQUEST_HEADER } from '@/lib/apiVersioning';

type RouteParams = {
params: {
route: string[];
};
};

async function proxyRequest(request: Request, params: RouteParams['params']) {
const routeSegments = params.route;

if (!routeSegments || routeSegments.length === 0) {
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}

const targetUrl = new URL(request.url);
targetUrl.pathname = `/api/${routeSegments.join('/')}`;

const forwardedHeaders = new Headers(request.headers);
forwardedHeaders.set(INTERNAL_API_REQUEST_HEADER, 'true');
forwardedHeaders.set(API_VERSION_HEADER, 'v1');

const init: RequestInit = {
method: request.method,
headers: forwardedHeaders,
redirect: 'manual',
};

if (request.method !== 'GET' && request.method !== 'HEAD') {
const body = await request.arrayBuffer();
if (body.byteLength > 0) {
init.body = body;
}
}

const response = await fetch(targetUrl.toString(), init);
const responseHeaders = new Headers(response.headers);
responseHeaders.set(API_VERSION_HEADER, 'v1');

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}

export async function GET(request: Request, { params }: RouteParams) {
return proxyRequest(request, params);
}

export async function POST(request: Request, { params }: RouteParams) {
return proxyRequest(request, params);
}

export async function PUT(request: Request, { params }: RouteParams) {
return proxyRequest(request, params);
}

export async function PATCH(request: Request, { params }: RouteParams) {
return proxyRequest(request, params);
}

export async function DELETE(request: Request, { params }: RouteParams) {
return proxyRequest(request, params);
}

export async function OPTIONS(request: Request, { params }: RouteParams) {
return proxyRequest(request, params);
}
44 changes: 44 additions & 0 deletions src/components/seo/StructuredDataScript.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* StructuredDataScript Component
* Renders JSON-LD structured data as a script tag
* Improves SEO and accessibility for filter controls
*/

'use client';

import React from 'react';

interface StructuredDataScriptProps {
jsonLd: string;
id?: string;
}

/**
* Component to inject JSON-LD structured data into the page
* Uses type="application/ld+json" for proper schema.org recognition
*/
export const StructuredDataScript = React.memo<StructuredDataScriptProps>(
({ jsonLd, id = 'structured-data' }) => {
// Validate JSON-LD before rendering
let isValid = true;
try {
JSON.parse(jsonLd);
} catch {
isValid = false;
}

// Only render if valid to avoid console errors
if (!isValid) {
if (process.env.NODE_ENV === 'development') {
console.warn('StructuredDataScript: Invalid JSON-LD provided', jsonLd);
}
return null;
}

return (
<script type="application/ld+json" id={id} dangerouslySetInnerHTML={{ __html: jsonLd }} />
);
},
);

StructuredDataScript.displayName = 'StructuredDataScript';
1 change: 1 addition & 0 deletions src/lib/apiVersioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const VERSIONED_API_ROOT = `${API_ROOT}/${DEFAULT_API_VERSION}`;
export const API_VERSION_HEADER = 'X-Api-Version';
export const API_DEPRECATION_HEADER = 'X-Api-Deprecated';
export const API_DEPRECATION_INFO_HEADER = 'X-Api-Deprecation-Info';
export const INTERNAL_API_REQUEST_HEADER = 'X-Internal-Api-Request';

function isVersionedApiPath(path: string): boolean {
return path.startsWith(`${API_ROOT}/v`);
Expand Down
Loading
Loading