diff --git a/docs/code-dark-theme-description.md b/docs/code-dark-theme-description.md new file mode 100644 index 0000000..629c143 --- /dev/null +++ b/docs/code-dark-theme-description.md @@ -0,0 +1,60 @@ +# Add code-sample dark theme variant + +## Summary + +This PR adds dark theme CSS variants for the CodeExample component. Code samples now respect CSS custom properties with proper light/dark theme overrides, ensuring visual parity across both modes. + +## Changes + +### `src/styles/code.css` (new) +- CSS custom properties for code sample styling (`--bg-subtle`, `--bg-highlight`, `--border-subtle`, `--text-main`, `--font-mono`) +- Light theme defaults for all properties +- `[data-theme="dark"]` overrides for dark mode parity +- `.code-sample__tab` styles with active state +- `.code-sample__pre` styles for code display +- `.sr-only` utility class for screen reader announcements +- Reduced motion support via `prefers-reduced-motion` + +### `src/components/CodeExample.tsx` +- Refactored from inline styles to CSS classes +- Added import for `../styles/code.css` +- Replaced `preview-card` with `code-sample` wrapper class +- Uses semantic class names: `code-sample__header`, `code-sample__tabs`, `code-sample__tab`, `code-sample__tab--active`, `code-sample__panel`, `code-sample__pre` +- Removed embedded ` ); } \ No newline at end of file diff --git a/src/components/FiltersSidebar.test.tsx b/src/components/FiltersSidebar.test.tsx new file mode 100644 index 0000000..fddf3c2 --- /dev/null +++ b/src/components/FiltersSidebar.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import FiltersSidebar from "./FiltersSidebar"; + +const baseProps = { + selectedCategories: new Set(), + toggleCategory: vi.fn(), + minPrice: null, + maxPrice: null, + setMinPrice: vi.fn(), + setMaxPrice: vi.fn(), + popularity: "any", + setPopularity: vi.fn(), + clearFilters: vi.fn(), +}; + +describe("FiltersSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it("renders all filter sections", () => { + render(); + expect(screen.getByText("Categories")).toBeTruthy(); + expect(screen.getByText("Price range")).toBeTruthy(); + expect(screen.getByText("Popularity")).toBeTruthy(); + }); + + it("renders category checkboxes", () => { + render(); + expect(screen.getByLabelText("Data & Analytics")).toBeTruthy(); + expect(screen.getByLabelText("Payment Processing")).toBeTruthy(); + expect(screen.getByLabelText("Communication")).toBeTruthy(); + expect(screen.getByLabelText("AI/ML")).toBeTruthy(); + expect(screen.getByLabelText("Other")).toBeTruthy(); + }); + + it("calls toggleCategory when checkbox is clicked", () => { + const toggleCategory = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Data & Analytics")); + expect(toggleCategory).toHaveBeenCalledWith("Data & Analytics"); + }); + + describe("collapse functionality", () => { + it("expands panel by default when no persisted state", () => { + render(); + const categoriesPanel = screen.getByTestId("filter-panel-categories"); + expect(categoriesPanel.hasAttribute("hidden")).toBe(false); + }); + + it("persists collapsed state on toggle", () => { + const { unmount } = render(); + + const header = screen.getByRole("button", { name: "Categories" }); + fireEvent.click(header); + + expect(localStorage.getItem("callora.filters.categories.collapsed")).toBe("true"); + unmount(); + }); + + it("restores collapsed state from localStorage on re-render", () => { + localStorage.setItem("callora.filters.categories.collapsed", "true"); + + render(); + const categoriesPanel = screen.getByTestId("filter-panel-categories"); + expect(categoriesPanel.hasAttribute("hidden")).toBe(true); + }); + + it("restores expanded state from localStorage when set to false", () => { + localStorage.setItem("callora.filters.popularity.collapsed", "false"); + + render(); + const popularityPanel = screen.getByTestId("filter-panel-popularity"); + expect(popularityPanel.hasAttribute("hidden")).toBe(false); + }); + + it("sets aria-expanded to true when panel is expanded", () => { + render(); + const header = screen.getByRole("button", { name: "Categories" }); + expect(header.getAttribute("aria-expanded")).toBe("true"); + }); + + it("sets aria-expanded to false when panel is collapsed", () => { + localStorage.setItem("callora.filters.categories.collapsed", "true"); + + render(); + const header = screen.getByRole("button", { name: "Categories" }); + expect(header.getAttribute("aria-expanded")).toBe("false"); + }); + + it("has correct aria-controls attribute on header", () => { + render(); + const header = screen.getByRole("button", { name: "Categories" }); + expect(header.getAttribute("aria-controls")).toBe("filter-panel-categories"); + }); + + it("collapses all sections independently", () => { + render(); + + const priceHeader = screen.getByRole("button", { name: "Price range" }); + fireEvent.click(priceHeader); + + const categoriesPanel = screen.getByTestId("filter-panel-categories"); + const pricePanel = screen.getByTestId("filter-panel-price"); + const popularityPanel = screen.getByTestId("filter-panel-popularity"); + + expect(categoriesPanel.hasAttribute("hidden")).toBe(false); + expect(pricePanel.hasAttribute("hidden")).toBe(true); + expect(popularityPanel.hasAttribute("hidden")).toBe(false); + }); + + it("rotates chevron when collapsed", () => { + localStorage.setItem("callora.filters.categories.collapsed", "true"); + + render(); + const header = screen.getByRole("button", { name: "Categories" }); + const chevron = header.querySelector(".filter-group__chevron"); + expect(chevron?.classList.contains("filter-group__chevron--collapsed")).toBe(true); + }); + }); + + describe("clear filters", () => { + it("calls clearFilters when Clear filters button is clicked", () => { + const clearFilters = vi.fn(); + render(); + fireEvent.click(screen.getByText("Clear filters")); + expect(clearFilters).toHaveBeenCalledTimes(1); + }); + }); + + describe("price validation", () => { + it("shows error when min price exceeds max price", () => { + render( + + ); + expect(screen.getByText(/Min price cannot exceed max price/i)).toBeTruthy(); + }); + + it("does not show error when prices are valid", () => { + render( + + ); + expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull(); + }); + + it("does not show error when both prices are null", () => { + render(); + expect(screen.queryByText(/Min price cannot exceed max price/i)).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/FiltersSidebar.tsx b/src/components/FiltersSidebar.tsx index b4d6b02..0e5751f 100644 --- a/src/components/FiltersSidebar.tsx +++ b/src/components/FiltersSidebar.tsx @@ -1,4 +1,6 @@ -import { WarningIcon } from "./icons"; +import React from "react"; +import { WarningIcon, ChevronIcon } from "./icons"; +import { usePersistedState } from "../hooks/usePersistedState"; export const ALL_CATEGORIES = [ "Data & Analytics", @@ -8,6 +10,51 @@ export const ALL_CATEGORIES = [ "Other", ]; +interface FilterGroupProps { + title: string; + storageKey: "categories" | "price" | "popularity"; + children: React.ReactNode; +} + +function FilterGroup({ title, storageKey, children }: FilterGroupProps) { + const [collapsed, setCollapsed] = usePersistedState( + `callora.filters.${storageKey}.collapsed`, + false, + ); + + const handleToggle = () => setCollapsed(!collapsed); + + return ( +
+ + +
+ ); +} + export default function FiltersSidebar({ selectedCategories, toggleCategory, @@ -31,99 +78,88 @@ export default function FiltersSidebar({ }) { return ( ); -} +} \ No newline at end of file diff --git a/src/components/icons/ChevronIcon.tsx b/src/components/icons/ChevronIcon.tsx new file mode 100644 index 0000000..751eb75 --- /dev/null +++ b/src/components/icons/ChevronIcon.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +interface IconProps extends React.SVGProps { + size?: 16 | 20; +} + +export function ChevronIcon({ size = 16, className, ...props }: IconProps) { + const ariaHidden = props["aria-label"] ? undefined : "true"; + + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/icons/LinkIcon.tsx b/src/components/icons/LinkIcon.tsx new file mode 100644 index 0000000..a2bbfa8 --- /dev/null +++ b/src/components/icons/LinkIcon.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +interface IconProps extends React.SVGProps { + size?: 16 | 20; +} + +export function LinkIcon({ size = 16, className, ...props }: IconProps) { + const ariaHidden = props["aria-label"] ? undefined : "true"; + + return ( + + + + + ); +} \ No newline at end of file diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 765e4df..5591c74 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -3,3 +3,5 @@ export { CheckIcon } from "./CheckIcon"; export { WarningIcon } from "./WarningIcon"; export { BoltIcon } from "./BoltIcon"; export { ClockIcon } from "./ClockIcon"; +export { ChevronIcon } from "./ChevronIcon"; +export { LinkIcon } from "./LinkIcon"; diff --git a/src/index.css b/src/index.css index fa8fa54..d0dd3ae 100644 --- a/src/index.css +++ b/src/index.css @@ -3968,3 +3968,67 @@ code, .api-marketplace-card:focus-within .api-card__compare-btn { opacity: 1 !important; } + +/* ─── Filter Group Collapsible Styles ─────────────────────────────────────────── */ +.filter-group__header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px 16px; + background: var(--surface-soft); + border: 1px solid var(--line); + border-radius: var(--radius-md); + color: var(--text); + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: background 180ms ease, border-color 180ms ease; +} + +.filter-group__header:hover, +.filter-group__header:focus-visible { + background: var(--line); + border-color: var(--accent); + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.filter-group__chevron { + transition: transform 180ms ease; +} + +.filter-group__chevron--collapsed { + transform: rotate(180deg); +} + +.filter-group__panel { + padding: 8px 16px 0; +} + +@media (prefers-reduced-motion: reduce) { + .filter-group__header, + .filter-group__chevron { + transition: none; + } +} + +/* Responsive: adjust header padding on smaller screens */ +@media (max-width: 640px) { + .filter-group__header { + padding: 10px 14px; + font-size: 0.9rem; + } + + .filter-group__panel { + padding: 6px 14px 0; + } +} + +/* Share Snapshot Button */ +.share-snapshot-button { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; +} diff --git a/src/state/uiPrefs.test.ts b/src/state/uiPrefs.test.ts new file mode 100644 index 0000000..c70f254 --- /dev/null +++ b/src/state/uiPrefs.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { + isSectionCollapsed, + toggleSectionCollapsed, + setSectionCollapsed, +} from './uiPrefs'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('uiPrefs - collapsed sections', () => { + describe('isSectionCollapsed', () => { + it('returns false when section not stored', () => { + expect(isSectionCollapsed('categories')).toBe(false); + }); + + it('returns true when section is stored as collapsed', () => { + localStorage.setItem('callora.filters.collapsed', JSON.stringify(['categories'])); + expect(isSectionCollapsed('categories')).toBe(true); + }); + + it('returns false when section is stored as expanded', () => { + // Empty array means no sections collapsed + localStorage.setItem('callora.filters.collapsed', JSON.stringify([])); + expect(isSectionCollapsed('categories')).toBe(false); + }); + }); + + describe('toggleSectionCollapsed', () => { + it('returns true when collapsing an uncollapsed section', () => { + expect(toggleSectionCollapsed('categories')).toBe(true); + expect(isSectionCollapsed('categories')).toBe(true); + }); + + it('returns false when expanding a collapsed section', () => { + localStorage.setItem('callora.filters.collapsed', JSON.stringify(['categories'])); + expect(toggleSectionCollapsed('categories')).toBe(false); + expect(isSectionCollapsed('categories')).toBe(false); + }); + + it('does not affect other sections', () => { + localStorage.setItem('callora.filters.collapsed', JSON.stringify(['price'])); + toggleSectionCollapsed('categories'); + expect(isSectionCollapsed('price')).toBe(true); + expect(isSectionCollapsed('categories')).toBe(true); + }); + }); + + describe('setSectionCollapsed', () => { + it('collapses a section when set to true', () => { + setSectionCollapsed('price', true); + expect(isSectionCollapsed('price')).toBe(true); + }); + + it('expands a section when set to false', () => { + localStorage.setItem('callora.filters.collapsed', JSON.stringify(['price'])); + setSectionCollapsed('price', false); + expect(isSectionCollapsed('price')).toBe(false); + }); + + it('does not affect other sections', () => { + localStorage.setItem('callora.filters.collapsed', JSON.stringify(['categories'])); + setSectionCollapsed('price', true); + expect(isSectionCollapsed('categories')).toBe(true); + expect(isSectionCollapsed('price')).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/state/uiPrefs.ts b/src/state/uiPrefs.ts index ad6e50c..8743f2b 100644 --- a/src/state/uiPrefs.ts +++ b/src/state/uiPrefs.ts @@ -22,4 +22,68 @@ export function setDensityPreference( } return density; +} + +// Collapsed sections persistence for FiltersSidebar +const COLLAPSED_SECTIONS_KEY = "callora.filters.collapsed"; +const FILTER_SECTIONS = ["categories", "price", "popularity"] as const; +type FilterSection = (typeof FILTER_SECTIONS)[number]; + +function areCollapsible(filterSection: string): filterSection is FilterSection { + return FILTER_SECTIONS.includes(filterSection as FilterSection); +} + +function getStoredCollapsedSections(): Set { + if (typeof window === "undefined") { + return new Set(); + } + try { + const stored = window.localStorage.getItem(COLLAPSED_SECTIONS_KEY); + if (!stored) return new Set(); + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return new Set(parsed.filter(areCollapsible)); + } + return new Set(); + } catch { + return new Set(); + } +} + +function storeCollapsedSections(sections: Set): void { + if (typeof window !== "undefined") { + try { + window.localStorage.setItem( + COLLAPSED_SECTIONS_KEY, + JSON.stringify(Array.from(sections)), + ); + } catch { + // Silently fail if localStorage is unavailable + } + } +} + +export function isSectionCollapsed(section: FilterSection): boolean { + return getStoredCollapsedSections().has(section); +} + +export function toggleSectionCollapsed(section: FilterSection): boolean { + const collapsed = getStoredCollapsedSections(); + if (collapsed.has(section)) { + collapsed.delete(section); + } else { + collapsed.add(section); + } + storeCollapsedSections(collapsed); + return collapsed.has(section); +} + +export function setSectionCollapsed(section: FilterSection, collapsed: boolean): void { + const sections = getStoredCollapsedSections(); + if (collapsed) { + sections.add(section); + } else { + sections.delete(section); + } + storeCollapsedSections(sections); } \ No newline at end of file diff --git a/src/styles/code.css b/src/styles/code.css new file mode 100644 index 0000000..f9b7840 --- /dev/null +++ b/src/styles/code.css @@ -0,0 +1,106 @@ +/* Code Example Design Tokens — Light and Dark Theme Variants */ + +/* Light theme defaults */ +.code-sample { + --bg-subtle: #f9f9f9; + --bg-highlight: #ffffff; + --border-subtle: #e2e8f0; + --text-main: #1a2332; + --font-mono: "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; +} + +/* Dark theme overrides */ +[data-theme="dark"] .code-sample { + --bg-subtle: rgba(255, 255, 255, 0.03); + --bg-highlight: var(--surface-strong, rgba(17, 24, 46, 0.96)); + --border-subtle: var(--line, rgba(169, 184, 255, 0.16)); + --text-main: var(--text, #f3f5fb); +} + +/* Code sample container */ +.code-sample { + background: var(--bg-subtle); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + overflow: hidden; +} + +.code-sample__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border-subtle); +} + +.code-sample__tabs { + display: flex; + gap: 4px; +} + +.code-sample__tab { + padding: 4px 10px; + font-size: 11px; + font-weight: 400; + color: var(--muted, #64748b); + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + transition: all 180ms ease; +} + +.code-sample__tab--active { + font-weight: 600; + color: var(--text-main); + background: var(--bg-highlight); + border-color: var(--border-subtle); +} + +.code-sample__tab:focus-visible { + outline: 2px solid var(--accent, #4e85ff); + outline-offset: 2px; +} + +.code-sample__copy { + padding: 5px 12px; + font-size: 11px; + min-width: 75px; +} + +.code-sample__copy--success { + color: var(--success, #10b981); +} + +.code-sample__panel { + padding: 16px 12px; + margin: 0; +} + +.code-sample__pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + color: var(--text-main); +} + +/* Screen reader only text */ +.sr-only { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .code-sample__tab { + transition: none; + } +} \ No newline at end of file diff --git a/src/utils/snapshotUrl.test.ts b/src/utils/snapshotUrl.test.ts new file mode 100644 index 0000000..feaccaf --- /dev/null +++ b/src/utils/snapshotUrl.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { generateSnapshotUrl, parseSnapshotUrl, copySnapshotUrl } from './snapshotUrl'; + +describe('snapshotUrl', () => { + describe('generateSnapshotUrl', () => { + it('generates URL with just endpoint ID when no params', () => { + const url = generateSnapshotUrl('/usage', { endpointId: 'endpoint-1', params: null }); + expect(url).toBe('/usage?endpoint=endpoint-1'); + }); + + it('generates URL with encoded params when provided', () => { + const url = generateSnapshotUrl('/usage', { + endpointId: 'endpoint-2', + params: { amount: 100, currency: 'USD' }, + }); + expect(url).toContain('endpoint=endpoint-2'); + expect(url).toContain('params='); + }); + + it('handles empty params object', () => { + const url = generateSnapshotUrl('/usage', { endpointId: 'endpoint-1', params: {} }); + expect(url).toContain('endpoint=endpoint-1'); + expect(url).toContain('params='); + }); + + it('handles special characters in params', () => { + const url = generateSnapshotUrl('/usage', { + endpointId: 'endpoint-3', + params: { note: 'hello "world"' }, + }); + expect(url).toContain('endpoint=endpoint-3'); + expect(() => decodeURIComponent(url.split('params=')[1])).not.toThrow(); + }); + }); + + describe('parseSnapshotUrl', () => { + it('returns null when endpoint parameter missing', () => { + const result = parseSnapshotUrl('?other=value'); + expect(result).toBeNull(); + }); + + it('parses URL with just endpoint ID', () => { + const result = parseSnapshotUrl('?endpoint=endpoint-1'); + expect(result).toEqual({ endpointId: 'endpoint-1', params: null }); + }); + + it('parses URL with encoded params', () => { + const params = { amount: 100, currency: 'USD' }; + // Use same encoding as generateSnapshotUrl: btoa(unescape(encodeURIComponent(json))) + const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(params)))); + const result = parseSnapshotUrl(`?endpoint=endpoint-2¶ms=${encoded}`); + expect(result?.endpointId).toBe('endpoint-2'); + expect(result?.params).toEqual(params); + }); + + it('returns endpoint with null params when param decoding fails', () => { + // Malformed base64 that will fail to decode + const result = parseSnapshotUrl('?endpoint=endpoint-1¶ms=invalid!!!base64'); + expect(result?.endpointId).toBe('endpoint-1'); + expect(result?.params).toBeNull(); + }); + + it('handles empty search string', () => { + const result = parseSnapshotUrl(''); + expect(result).toBeNull(); + }); + }); + + describe('copySnapshotUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('copies URL to clipboard and returns true on success', async () => { + const clipboardMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: clipboardMock }, + writable: true, + }); + + const result = await copySnapshotUrl('/usage', { endpointId: 'endpoint-1', params: { test: 1 } }); + expect(result).toBe(true); + expect(clipboardMock).toHaveBeenCalled(); + }); + + it('returns false when clipboard write fails', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockRejectedValue(new Error('Not allowed')) }, + writable: true, + }); + + const result = await copySnapshotUrl('/usage', { endpointId: 'endpoint-1', params: null }); + expect(result).toBe(false); + consoleSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/snapshotUrl.ts b/src/utils/snapshotUrl.ts new file mode 100644 index 0000000..86d7b72 --- /dev/null +++ b/src/utils/snapshotUrl.ts @@ -0,0 +1,87 @@ +// src/utils/snapshotUrl.ts + +/** + * Generates a shareable URL snapshot capturing current endpoint and parameters. + * Parameters are URL-encoded and added as query string for easy sharing. + */ + +export interface EndpointSnapshot { + endpointId: string; + params: Record | null; +} + +/** + * Serializes endpoint state to a URL-safe query string format. + * - params: JSON stringified and base64 encoded to handle complex objects + * - Handles circular references gracefully by catching errors + */ +export function generateSnapshotUrl( + basePath: string, + snapshot: EndpointSnapshot, +): string { + const params = new URLSearchParams(); + params.set('endpoint', snapshot.endpointId); + + if (snapshot.params) { + try { + const json = JSON.stringify(snapshot.params); + // Use encodeURIComponent to handle Unicode, then btoa for binary-safe base64 + const encoded = btoa(unescape(encodeURIComponent(json))); + params.set('params', encoded); + } catch { + // Silently fail if JSON serialization fails + } + } + + return `${basePath}?${params.toString()}`; +} + +/** + * Parses a snapshot URL and extracts endpoint state. + * Returns null if params are malformed or missing. + */ +export function parseSnapshotUrl( + search: string, +): EndpointSnapshot | null { + const params = new URLSearchParams(search); + const endpointId = params.get('endpoint'); + + if (!endpointId) { + return null; + } + + const paramsEncoded = params.get('params'); + let parsedParams: Record | null = null; + + if (paramsEncoded) { + try { + // Decode: atob -> escape -> decodeURIComponent to get original JSON + const json = decodeURIComponent(escape(atob(paramsEncoded))); + parsedParams = JSON.parse(json); + } catch { + // Malformed params, silently ignore + } + } + + return { + endpointId, + params: parsedParams, + }; +} + +/** + * Copies a snapshot URL to clipboard for sharing. + * Returns true if successful, false otherwise. + */ +export async function copySnapshotUrl( + basePath: string, + snapshot: EndpointSnapshot, +): Promise { + const url = generateSnapshotUrl(basePath, snapshot); + try { + await navigator.clipboard.writeText(url); + return true; + } catch { + return false; + } +} \ No newline at end of file