diff --git a/web/frontend/.storybook/main.ts b/web/frontend/.storybook/main.ts index c2600398..eb9037c9 100644 --- a/web/frontend/.storybook/main.ts +++ b/web/frontend/.storybook/main.ts @@ -12,6 +12,12 @@ const config: StorybookConfig = { '../stories/RegisterForm.stories.tsx', '../stories/Navbar.stories.tsx', '../stories/Toast.stories.tsx', + '../stories/BreweryCard.stories.tsx', + '../stories/FeaturedBreweryCard.stories.tsx', + '../stories/RecentBreweries.stories.tsx', + '../stories/NearbyBreweriesSection.stories.tsx', + '../stories/BreweryMap.stories.tsx', + '../stories/NearbyBreweryMap.stories.tsx', '../stories/Themes.stories.tsx', ], addons: [ diff --git a/web/frontend/app/features/breweries/components/BreweryMap.tsx b/web/frontend/app/features/breweries/components/BreweryMap.tsx index 196f1c10..3d793dbd 100644 --- a/web/frontend/app/features/breweries/components/BreweryMap.tsx +++ b/web/frontend/app/features/breweries/components/BreweryMap.tsx @@ -72,9 +72,9 @@ const RecenterMap = ({ useEffect(() => { if (currentLocation) { - map.setView(currentLocation, CURRENT_LOCATION_ZOOM); + map.setView(currentLocation, CURRENT_LOCATION_ZOOM, { animate: false }); } else if (bounds.length > 0) { - map.fitBounds(bounds, { padding: [32, 32], maxZoom: 6 }); + map.fitBounds(bounds, { padding: [32, 32], maxZoom: 6, animate: false }); } }, [map, bounds, currentLocation]); diff --git a/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx index 8c750d7b..51ac7ee9 100644 --- a/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx +++ b/web/frontend/app/features/breweries/components/NearbyBreweriesSection.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useState } from 'react'; import { useFetcher } from 'react-router'; import type { LatLngTuple } from 'leaflet'; import ClientOnly from '../../../components/ClientOnly'; +import useDebouncedValue from '../../../hooks/useDebouncedValue'; import type { SimplifiedBrewery } from '../breweries.server'; import type { loader as nearbyLoader } from '../routes/breweries-nearby'; import { formatDistance, type DistanceUnit } from '../utils/distance'; @@ -14,6 +15,7 @@ const DEFAULT_RADIUS_KM = 120; const MIN_RADIUS_KM = 10; const MAX_RADIUS_KM = 150; const RADIUS_STEP_KM = 10; +const RADIUS_DEBOUNCE_MS = 300; export interface FeaturedPin { id: string; @@ -186,6 +188,7 @@ const NearbyBreweriesSection = ({ const [locationLabel, setLocationLabel] = useState('your location'); const [geoAttempted, setGeoAttempted] = useState(false); const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM); + const debouncedRadiusKm = useDebouncedValue(radiusKm, RADIUS_DEBOUNCE_MS); const [unit, setUnit] = useState('km'); const fetcher = useFetcher(); @@ -219,10 +222,10 @@ const NearbyBreweriesSection = ({ useEffect(() => { if (!center) return; fetcher.load( - `/breweries/nearby?latitude=${center[0]}&longitude=${center[1]}&rangeInMetres=${radiusKm * 1000}`, + `/breweries/nearby?latitude=${center[0]}&longitude=${center[1]}&rangeInMetres=${debouncedRadiusKm * 1000}`, ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [center?.[0], center?.[1], radiusKm]); + }, [center?.[0], center?.[1], debouncedRadiusKm]); const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? []; const withDistance = center diff --git a/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx b/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx index 6cfc8a72..423e9e40 100644 --- a/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx +++ b/web/frontend/app/features/breweries/components/NearbyBreweryMap.tsx @@ -46,10 +46,11 @@ const FitAndFocus = ({ const points: LatLngTuple[] = pins.map((pin) => [pin.latitude, pin.longitude]); if (userLocation) points.push(userLocation); if (points.length > 0) { - map.fitBounds(points, { padding: [40, 40] }); + map.fitBounds(points, { padding: [40, 40], animate: false }); didInitialFit.current = true; } - setTimeout(() => map.invalidateSize(), 200); + const timeout = setTimeout(() => map.invalidateSize(), 200); + return () => clearTimeout(timeout); }, [map, pins, userLocation]); useEffect(() => { diff --git a/web/frontend/app/hooks/useDebouncedValue.ts b/web/frontend/app/hooks/useDebouncedValue.ts new file mode 100644 index 00000000..9bf805d2 --- /dev/null +++ b/web/frontend/app/hooks/useDebouncedValue.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +/** + * Returns a value that only updates after `delayMs` has passed without `value` changing. + * + * @example + * const debouncedQuery = useDebouncedValue(query, 300); + */ +const useDebouncedValue = (value: T, delayMs: number): T => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timeout); + }, [value, delayMs]); + + return debounced; +}; + +export default useDebouncedValue; diff --git a/web/frontend/stories/BreweryCard.stories.tsx b/web/frontend/stories/BreweryCard.stories.tsx new file mode 100644 index 00000000..5a3ff81f --- /dev/null +++ b/web/frontend/stories/BreweryCard.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import BreweryCard from '../app/features/breweries/components/BreweryCard'; +import type { Brewery, BreweryLocation } from '../app/features/breweries/breweries.server'; +import { formatBreweryAddress } from '../app/features/breweries/utils/format-address'; + +const breweryCardDescription = `Link-card for a brewery shown in listing grids. These stories cover a brewery with a full mapped location, one whose location has no coordinates (no "View on map" link), and one with no location at all.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-1', + breweryPostId: 'brewery-1', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.5152, longitude: -122.6784 }, +}; + +const breweryWithLocation: Brewery = { + breweryPostId: 'brewery-1', + postedById: 'user-1', + breweryName: 'Cascade Hollow Brewing', + description: + 'A small-batch brewery known for hazy IPAs and barrel-aged sours, tucked into an old warehouse district.', + createdAt: '2024-03-01T12:00:00.000Z', + updatedAt: null, + location: portlandLocation, +}; + +const breweryWithoutCoordinates: Brewery = { + ...breweryWithLocation, + breweryPostId: 'brewery-2', + breweryName: 'Foggy Bottom Brewhouse', + location: { ...portlandLocation, coordinates: null }, +}; + +const breweryWithoutLocation: Brewery = { + ...breweryWithLocation, + breweryPostId: 'brewery-3', + breweryName: 'Unmapped Ales', + location: null, +}; + +const meta = { + title: 'Breweries/BreweryCard', + component: BreweryCard, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: breweryCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithLocation: Story = { + args: { brewery: breweryWithLocation }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('heading', { name: 'Cascade Hollow Brewing' }), + ).toBeInTheDocument(); + await expect(canvas.getByText('Portland, OR')).toBeInTheDocument(); + await expect(canvas.getByText(formatBreweryAddress(portlandLocation))).toBeInTheDocument(); + + const cardLink = canvas.getByRole('link', { name: /cascade hollow brewing/i }); + await expect(cardLink).toHaveAttribute('href', '/breweries/brewery-1'); + + const mapLink = canvas.getByRole('link', { name: 'View on map →', exact: true }); + await expect(mapLink).toHaveAttribute('href', expect.stringContaining('mlat=45.5152')); + await expect(mapLink).toHaveAttribute('target', '_blank'); + }, +}; + +export const WithoutCoordinates: Story = { + args: { brewery: breweryWithoutCoordinates }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Portland, OR')).toBeInTheDocument(); + await expect(canvas.queryByRole('link', { name: /view on map/i })).not.toBeInTheDocument(); + }, +}; + +export const WithoutLocation: Story = { + args: { brewery: breweryWithoutLocation }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('heading', { name: 'Unmapped Ales' })).toBeInTheDocument(); + await expect(canvas.queryByText('Portland, OR')).not.toBeInTheDocument(); + await expect(canvas.queryByRole('link', { name: /view on map/i })).not.toBeInTheDocument(); + }, +}; diff --git a/web/frontend/stories/BreweryMap.stories.tsx b/web/frontend/stories/BreweryMap.stories.tsx new file mode 100644 index 00000000..45f4b8b4 --- /dev/null +++ b/web/frontend/stories/BreweryMap.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import BreweryMap, { type BreweryMapPin } from '../app/features/breweries/components/BreweryMap'; +import type { BreweryLocation } from '../app/features/breweries/breweries.server'; + +const breweryMapDescription = `Leaflet map plotting brewery locations, with an optional current-location marker. Smoke-render only - this hits the real OpenStreetMap tile server in the browser test run, and its \`navigator.geolocation\` call is tolerated whether or not it succeeds.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-1', + breweryPostId: 'brewery-1', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.5152, longitude: -122.6784 }, +}; + +const pins: BreweryMapPin[] = [ + { + id: 'brewery-1', + name: 'Cascade Hollow Brewing', + latitude: 45.5152, + longitude: -122.6784, + location: portlandLocation, + }, + { + id: 'brewery-2', + name: 'Foggy Bottom Brewhouse', + latitude: 44.06, + longitude: -121.31, + location: null, + }, +]; + +const meta = { + title: 'Breweries/BreweryMap', + component: BreweryMap, + parameters: { + layout: 'padded', + docs: { + description: { + component: breweryMapDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { breweries: pins }, +}; + +export const NoBreweries: Story = { + args: { breweries: [] }, +}; diff --git a/web/frontend/stories/FeaturedBreweryCard.stories.tsx b/web/frontend/stories/FeaturedBreweryCard.stories.tsx new file mode 100644 index 00000000..75d29d4a --- /dev/null +++ b/web/frontend/stories/FeaturedBreweryCard.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import FeaturedBreweryCard from '../app/features/breweries/components/FeaturedBreweryCard'; +import type { Brewery, BreweryLocation } from '../app/features/breweries/breweries.server'; + +const featuredBreweryCardDescription = `Larger "featured brewery" card shown above the fold on the breweries page. These stories cover a brewery with mappable coordinates (the "Show on map" button is present and wired to \`onShowOnMap\`), one with a location but no coordinates, and one with no location at all.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-1', + breweryPostId: 'brewery-1', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.5152, longitude: -122.6784 }, +}; + +const breweryWithLocation: Brewery = { + breweryPostId: 'brewery-1', + postedById: 'user-1', + breweryName: 'Cascade Hollow Brewing', + description: + 'A small-batch brewery known for hazy IPAs and barrel-aged sours, tucked into an old warehouse district.', + createdAt: '2024-03-01T12:00:00.000Z', + updatedAt: null, + location: portlandLocation, +}; + +const breweryWithoutCoordinates: Brewery = { + ...breweryWithLocation, + breweryPostId: 'brewery-2', + breweryName: 'Foggy Bottom Brewhouse', + location: { ...portlandLocation, coordinates: null }, +}; + +const breweryWithoutLocation: Brewery = { + ...breweryWithLocation, + breweryPostId: 'brewery-3', + breweryName: 'Unmapped Ales', + location: null, +}; + +const meta = { + title: 'Breweries/FeaturedBreweryCard', + component: FeaturedBreweryCard, + tags: ['autodocs'], + args: { + onShowOnMap: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: featuredBreweryCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithCoordinates: Story = { + args: { brewery: breweryWithLocation }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('heading', { name: 'Cascade Hollow Brewing', level: 3 }), + ).toBeInTheDocument(); + await expect(canvas.getByText('Founded')).toBeInTheDocument(); + await expect(canvas.getByText('2014')).toBeInTheDocument(); + await expect(canvas.getByRole('link', { name: /view brewery/i })).toHaveAttribute( + 'href', + '/breweries/brewery-1', + ); + + await userEvent.click(canvas.getByRole('button', { name: /show on map/i })); + await expect(args.onShowOnMap).toHaveBeenCalledOnce(); + }, +}; + +export const WithoutCoordinates: Story = { + args: { brewery: breweryWithoutCoordinates }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole('button', { name: /show on map/i }), + ).not.toBeInTheDocument(); + }, +}; + +export const WithoutLocation: Story = { + args: { brewery: breweryWithoutLocation }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('heading', { name: 'Unmapped Ales' })).toBeInTheDocument(); + await expect( + canvas.queryByRole('button', { name: /show on map/i }), + ).not.toBeInTheDocument(); + }, +}; diff --git a/web/frontend/stories/NearbyBreweriesSection.stories.tsx b/web/frontend/stories/NearbyBreweriesSection.stories.tsx new file mode 100644 index 00000000..8e5b2e2c --- /dev/null +++ b/web/frontend/stories/NearbyBreweriesSection.stories.tsx @@ -0,0 +1,183 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { expect, fireEvent, waitFor, within } from 'storybook/test'; +import NearbyBreweriesSection from '../app/features/breweries/components/NearbyBreweriesSection'; +import type { + BreweryLocation, + SimplifiedBrewery, +} from '../app/features/breweries/breweries.server'; + +const nearbyBreweriesSectionDescription = `Requests geolocation (falling back to \`fallbackCenter\` when it's unavailable or denied, as it reliably is in headless browser tests), then loads nearby breweries via \`useFetcher().load()\` against the \`/breweries/nearby\` route, re-fetching whenever the debounced search radius changes. This harness runs the real component behind a \`createMemoryRouter\` with a fake loader for that route, keyed by the requested radius, so the \`Default\` story's play function can prove both the initial load and that rapid radius changes are debounced into a single re-fetch rather than one per step. The lazily-loaded Leaflet map mounts for real here (Storybook's client-only render means \`ClientOnly\` reports "mounted" immediately) and will issue real tile requests - the play function doesn't assert anything about it.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-close', + breweryPostId: 'brewery-close', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.52, longitude: -122.68 }, +}; + +const closeQuarters: SimplifiedBrewery = { + breweryPostId: 'brewery-close', + breweryName: 'Close Quarters Brewing', + location: portlandLocation, + distanceMetres: 4800, +}; + +const midrangeMalts: SimplifiedBrewery = { + breweryPostId: 'brewery-mid', + breweryName: 'Midrange Malts', + location: { + ...portlandLocation, + breweryPostLocationId: 'loc-mid', + breweryPostId: 'brewery-mid', + cityName: 'Salem', + coordinates: { latitude: 44.94, longitude: -123.03 }, + }, + distanceMetres: 52000, +}; + +const farFlungFermentery: SimplifiedBrewery = { + breweryPostId: 'brewery-far', + breweryName: 'Far Flung Fermentery', + location: { + ...portlandLocation, + breweryPostLocationId: 'loc-far', + breweryPostId: 'brewery-far', + cityName: 'Bend', + coordinates: { latitude: 44.06, longitude: -121.31 }, + }, + distanceMetres: 138000, +}; + +const FALLBACK_CENTER = { latitude: 45.5152, longitude: -122.6784, label: 'Portland, OR' }; + +// Keyed by rangeInMetres so the play function can prove the radius slider +// drives refetches. DEFAULT_RADIUS_KM=120 -> 120000; MAX_RADIUS_KM=150 -> 150000. +const NEARBY_FIXTURES: Record = { + 30000: [closeQuarters], + 60000: [closeQuarters], + 90000: [closeQuarters, midrangeMalts], + 120000: [closeQuarters, midrangeMalts], + 150000: [closeQuarters, midrangeMalts, farFlungFermentery], +}; + +let fetchedRanges: number[] = []; + +const NearbyBreweriesRoute = () => { + const [selectedId, setSelectedId] = useState(null); + return ( + + ); +}; + +const NearbyBreweriesHarness = () => { + const [router] = useState(() => { + fetchedRanges = []; + return createMemoryRouter([ + { path: '/', element: }, + { + path: '/breweries/nearby', + loader: async ({ request }) => { + const rangeInMetres = Number( + new URL(request.url).searchParams.get('rangeInMetres'), + ); + fetchedRanges.push(rangeInMetres); + return { breweries: NEARBY_FIXTURES[rangeInMetres] ?? [] }; + }, + }, + ]); + }); + + return ; +}; + +const NearbyBreweriesEmptyHarness = () => { + const [router] = useState(() => + createMemoryRouter([ + { path: '/', element: }, + { + path: '/breweries/nearby', + loader: async () => ({ breweries: [] as SimplifiedBrewery[] }), + }, + ]), + ); + + return ; +}; + +const meta = { + title: 'Breweries/NearbyBreweriesSection', + component: NearbyBreweriesHarness, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + usesDataRouter: true, + docs: { + description: { + component: nearbyBreweriesSectionDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Initial (undebounced) fetch at the default radius (120km -> 120000m). + await waitFor( + () => expect(canvas.getByText('Close Quarters Brewing')).toBeInTheDocument(), + { timeout: 3000 }, + ); + await expect(canvas.getByText('Midrange Malts')).toBeInTheDocument(); + await expect(fetchedRanges).toEqual([120000]); + + // Simulate a fast drag through several intermediate values with no + // waits in between. + const radiusSlider = canvas.getByRole('slider', { name: /search radius/i }); + fireEvent.change(radiusSlider, { target: { value: '30' } }); + fireEvent.change(radiusSlider, { target: { value: '60' } }); + fireEvent.change(radiusSlider, { target: { value: '90' } }); + fireEvent.change(radiusSlider, { target: { value: '150' } }); + + // The radius label updates immediately (it isn't debounced)... + await expect(canvas.getByText('150 km')).toBeInTheDocument(); + // ...but no new fetch has fired yet - the 300ms debounce window hasn't elapsed. + await expect(fetchedRanges).toEqual([120000]); + + // After the debounce window, exactly one more fetch happens, for the + // final radius only - not one per intermediate step. + await waitFor(() => expect(canvas.getByText('Far Flung Fermentery')).toBeInTheDocument(), { + timeout: 3000, + }); + await expect(fetchedRanges).toEqual([120000, 150000]); + }, +}; + +export const NoBreweriesWithinRadius: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor( + () => expect(canvas.getByText(/no partner breweries within/i)).toBeInTheDocument(), + { timeout: 3000 }, + ); + }, +}; diff --git a/web/frontend/stories/NearbyBreweryMap.stories.tsx b/web/frontend/stories/NearbyBreweryMap.stories.tsx new file mode 100644 index 00000000..36178237 --- /dev/null +++ b/web/frontend/stories/NearbyBreweryMap.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import NearbyBreweryMap, { + type NearbyMapPin, +} from '../app/features/breweries/components/NearbyBreweryMap'; + +const nearbyBreweryMapDescription = `Leaflet map of nearby-brewery pins used inside NearbyBreweriesSection. Smoke-render only - this hits the real OpenStreetMap tile server in the browser test run. Wrapped in a fixed-height container here since its \`h-full\` class normally relies on a CSS grid ancestor that this story doesn't provide.`; + +const pins: NearbyMapPin[] = [ + { + id: 'brewery-close', + name: 'Close Quarters Brewing', + cityLabel: 'Portland, OR', + latitude: 45.52, + longitude: -122.68, + }, + { + id: 'brewery-mid', + name: 'Midrange Malts', + cityLabel: 'Salem, OR', + latitude: 44.94, + longitude: -123.03, + }, +]; + +const meta = { + title: 'Breweries/NearbyBreweryMap', + component: NearbyBreweryMap, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + layout: 'padded', + docs: { + description: { + component: nearbyBreweryMapDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + pins, + selectedId: null, + onSelect: () => undefined, + userLocation: [45.5152, -122.6784], + userLocationLabel: 'your location', + }, +}; + +export const WithoutUserLocation: Story = { + args: { + pins, + selectedId: null, + onSelect: () => undefined, + userLocation: null, + userLocationLabel: 'your location', + }, +}; diff --git a/web/frontend/stories/RecentBreweries.stories.tsx b/web/frontend/stories/RecentBreweries.stories.tsx new file mode 100644 index 00000000..b07c5019 --- /dev/null +++ b/web/frontend/stories/RecentBreweries.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import RecentBreweries from '../app/features/breweries/components/RecentBreweries'; +import type { Brewery, BreweryLocation } from '../app/features/breweries/breweries.server'; + +const recentBreweriesDescription = `Grid of recently-added breweries shown on the breweries index page. Renders nothing when the list is empty.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-1', + breweryPostId: 'brewery-1', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.5152, longitude: -122.6784 }, +}; + +const cascadeHollow: Brewery = { + breweryPostId: 'brewery-1', + postedById: 'user-1', + breweryName: 'Cascade Hollow Brewing', + description: 'A small-batch brewery known for hazy IPAs and barrel-aged sours.', + createdAt: '2024-03-01T12:00:00.000Z', + updatedAt: null, + location: portlandLocation, +}; + +const midrangeMalts: Brewery = { + breweryPostId: 'brewery-2', + postedById: 'user-2', + breweryName: 'Midrange Malts', + description: 'A neighborhood taproom pouring approachable session ales year-round.', + createdAt: '2024-04-15T09:30:00.000Z', + updatedAt: null, + location: { ...portlandLocation, breweryPostId: 'brewery-2', cityName: 'Salem' }, +}; + +const unmappedAles: Brewery = { + breweryPostId: 'brewery-3', + postedById: 'user-3', + breweryName: 'Unmapped Ales', + description: 'A pop-up brewery that hasn’t added a storefront location yet.', + createdAt: '2024-05-02T18:00:00.000Z', + updatedAt: null, + location: null, +}; + +const meta = { + title: 'Breweries/RecentBreweries', + component: RecentBreweries, + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: recentBreweriesDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithBreweries: Story = { + args: { breweries: [cascadeHollow, midrangeMalts, unmappedAles] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('heading', { name: 'Recently added' })).toBeInTheDocument(); + await expect( + canvas.getByRole('heading', { name: 'Cascade Hollow Brewing' }), + ).toBeInTheDocument(); + + const links = canvas.getAllByRole('link', { name: /view brewery/i }); + await expect(links).toHaveLength(3); + await expect(links[0]).toHaveAttribute('href', '/breweries/brewery-1'); + }, +}; + +export const Empty: Story = { + args: { breweries: [] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.queryByRole('heading', { name: 'Recently added' }), + ).not.toBeInTheDocument(); + }, +};