mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Feature ## What is the current behavior? Region and data residency information is split across docs, `/security`, and legal pages. [MARKET-1866](https://linear.app/supabase/issue/MARKET-1866/package-and-display-available-regions-better-on-website) ## What is the new behavior? Adds `/regions`: a catalog of all 17 regions generated from `regions.ts`, plus what stays in-region, the Europe vs EU caveat, and links to the DPA, GDPR guide, sub-processor list, and security page. Regions is in the footer under Security & Compliance. The security page residency card now links here. ## Test plan - [ ] Open `/regions` in light and dark mode - [ ] Confirm the region count and list match `packages/shared-data/regions.ts` - [ ] Confirm footer Regions link and `/security` residency link go to `/regions` Made with [Cursor](https://cursor.com) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a Regions page showcasing available AWS regions by geography. - Added an interactive map and region list with selection, hover states, keyboard accessibility, and residency badges. - Included data residency guidance, legal resources, and a call-to-action for next steps. - Added Regions links in the site footer and security documentation. - **Documentation** - Updated agent skill resources with expanded troubleshooting and operational guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Francesco Sansalvadore <f.sansalvadore@gmail.com>
160 lines
5.6 KiB
TypeScript
160 lines
5.6 KiB
TypeScript
'use client'
|
|
|
|
import { geoEqualEarth } from 'd3-geo'
|
|
import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps'
|
|
import { cn } from 'ui'
|
|
|
|
import GeographyData from './MapData.json'
|
|
import { MAPPED_REGIONS } from './Regions.constants'
|
|
import { RegionTooltip } from './RegionTooltip'
|
|
|
|
/**
|
|
* The viewport is sized and framed so the land mass fills it edge to edge — the map is
|
|
* letterboxed inside its container otherwise. At scale 155 the world (Antarctica is not in
|
|
* MapData.json) projects to 828.3 x 361.5, so MAP_ZOOM shrinks it onto MAP_WIDTH and
|
|
* MAP_CENTER is the coordinate at the middle of that bounding box.
|
|
*/
|
|
const MAP_SCALE = 155
|
|
const MAP_WIDTH = 800
|
|
const MAP_HEIGHT = 349
|
|
const MAP_ZOOM = 0.9658
|
|
const MAP_CENTER: [number, number] = [1.08, 6.9]
|
|
|
|
const projection = geoEqualEarth()
|
|
.scale(MAP_SCALE)
|
|
.translate([MAP_WIDTH / 2, MAP_HEIGHT / 2])
|
|
const [centerX, centerY] = projection(MAP_CENTER) ?? [MAP_WIDTH / 2, MAP_HEIGHT / 2]
|
|
|
|
/**
|
|
* Where each marker sits as a percentage of the container, mirroring what ZoomableGroup does to
|
|
* the SVG: `translate(width / 2 - centre * zoom) scale(zoom)`. The tooltip is positioned with
|
|
* these because anything rendered inside the SVG scales with the viewBox — text ends up at half
|
|
* size on a phone and twice that on a desktop.
|
|
*/
|
|
const REGION_POSITIONS = new Map(
|
|
MAPPED_REGIONS.map((region) => {
|
|
const [x, y] = projection(region.coordinates) ?? [centerX, centerY]
|
|
|
|
return [
|
|
region.code,
|
|
{
|
|
left: ((MAP_WIDTH / 2 + (x - centerX) * MAP_ZOOM) / MAP_WIDTH) * 100,
|
|
top: ((MAP_HEIGHT / 2 + (y - centerY) * MAP_ZOOM) / MAP_HEIGHT) * 100,
|
|
},
|
|
]
|
|
})
|
|
)
|
|
|
|
interface Props {
|
|
selectedRegionCode: string | null
|
|
hoveredRegionCode?: string
|
|
onRegionHover: (code?: string) => void
|
|
onRegionSelect: (code: string) => void
|
|
onRegionClear: () => void
|
|
}
|
|
|
|
export const RegionsMap = ({
|
|
selectedRegionCode,
|
|
hoveredRegionCode,
|
|
onRegionHover,
|
|
onRegionSelect,
|
|
onRegionClear,
|
|
}: Props) => {
|
|
// Hovering previews a region, so it wins over the selection
|
|
const shownRegionCode = hoveredRegionCode ?? selectedRegionCode
|
|
const shownRegion = MAPPED_REGIONS.find((region) => region.code === shownRegionCode)
|
|
const shownPosition = shownRegion ? REGION_POSITIONS.get(shownRegion.code) : undefined
|
|
// Markers in the right half of the map get their tooltip on the left, so it can't run off
|
|
const isTooltipOnLeft = (shownPosition?.left ?? 0) > 50
|
|
|
|
return (
|
|
<>
|
|
<ComposableMap
|
|
width={MAP_WIDTH}
|
|
height={MAP_HEIGHT}
|
|
projectionConfig={{ scale: MAP_SCALE }}
|
|
className="absolute inset-0 h-full w-full"
|
|
>
|
|
<ZoomableGroup
|
|
center={MAP_CENTER}
|
|
zoom={MAP_ZOOM}
|
|
minZoom={MAP_ZOOM}
|
|
maxZoom={MAP_ZOOM}
|
|
// The map is a static illustration: no panning, no zooming
|
|
filterZoomEvent={() => false}
|
|
>
|
|
<Geographies geography={GeographyData}>
|
|
{({ geographies }) =>
|
|
geographies.map((geo) => (
|
|
<Geography
|
|
key={geo.rsmKey}
|
|
geography={geo}
|
|
strokeWidth={0.3}
|
|
pointerEvents="none"
|
|
className="fill-foreground-muted/10 stroke-border-strong dark:fill-background-surface-300 dark:stroke-border-strong"
|
|
/>
|
|
))
|
|
}
|
|
</Geographies>
|
|
|
|
{MAPPED_REGIONS.map((region) => {
|
|
const isSelected = region.code === selectedRegionCode
|
|
const isHighlighted = isSelected || region.code === hoveredRegionCode
|
|
|
|
return (
|
|
<Marker
|
|
key={region.key}
|
|
coordinates={region.coordinates}
|
|
onMouseEnter={() => onRegionHover(region.code)}
|
|
onMouseLeave={() => onRegionHover(undefined)}
|
|
onClick={() => onRegionSelect(region.code)}
|
|
>
|
|
{isSelected && <circle r={4} className="animate-ping fill-brand" />}
|
|
<circle
|
|
r={isHighlighted ? 4 : 3.5}
|
|
strokeWidth={1}
|
|
className={cn(
|
|
'cursor-pointer transition-colors',
|
|
isSelected
|
|
? 'fill-brand-500 stroke-brand-600'
|
|
: isHighlighted
|
|
? 'fill-brand stroke-brand-600'
|
|
: 'fill-brand-600 stroke-brand-400'
|
|
)}
|
|
/>
|
|
</Marker>
|
|
)
|
|
})}
|
|
</ZoomableGroup>
|
|
</ComposableMap>
|
|
|
|
{shownRegion && shownPosition && (
|
|
<div
|
|
className="pointer-events-none absolute z-10 -translate-y-1/2"
|
|
style={{
|
|
// Anchored to the free side of the marker, capped at the space there is on that side
|
|
// so a tooltip never runs past the edge, and kept a half-height clear of the top and
|
|
// bottom edges
|
|
...(isTooltipOnLeft
|
|
? {
|
|
right: `calc(${100 - shownPosition.left}% + 0.625rem)`,
|
|
maxWidth: `calc(${shownPosition.left}% - 1rem)`,
|
|
}
|
|
: {
|
|
left: `calc(${shownPosition.left}% + 0.625rem)`,
|
|
maxWidth: `calc(${100 - shownPosition.left}% - 1rem)`,
|
|
}),
|
|
top: `clamp(1.125rem, ${shownPosition.top}%, calc(100% - 1.125rem))`,
|
|
}}
|
|
>
|
|
<RegionTooltip
|
|
region={shownRegion}
|
|
isSelected={shownRegion.code === selectedRegionCode}
|
|
onClear={onRegionClear}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|