diff --git a/STRUCTURED_DATA_IMPLEMENTATION.md b/STRUCTURED_DATA_IMPLEMENTATION.md new file mode 100644 index 00000000..eb608d32 --- /dev/null +++ b/STRUCTURED_DATA_IMPLEMENTATION.md @@ -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 diff --git a/src/app/api/tipping/route.ts b/src/app/api/tipping/route.ts index 58ead727..81fa8355 100644 --- a/src/app/api/tipping/route.ts +++ b/src/app/api/tipping/route.ts @@ -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 { @@ -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 }); diff --git a/src/app/api/v1/[...route]/__tests__/route.test.ts b/src/app/api/v1/[...route]/__tests__/route.test.ts new file mode 100644 index 00000000..6e4d4e94 --- /dev/null +++ b/src/app/api/v1/[...route]/__tests__/route.test.ts @@ -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' }); + }); +}); diff --git a/src/app/api/v1/[...route]/route.ts b/src/app/api/v1/[...route]/route.ts new file mode 100644 index 00000000..35e73ad7 --- /dev/null +++ b/src/app/api/v1/[...route]/route.ts @@ -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); +} diff --git a/src/components/seo/StructuredDataScript.tsx b/src/components/seo/StructuredDataScript.tsx new file mode 100644 index 00000000..831978b2 --- /dev/null +++ b/src/components/seo/StructuredDataScript.tsx @@ -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( + ({ 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 ( +