feat: anonymous pre-market release per unit

Add anonymous flag to schattenmarktRelease on PropertyUnit and Property.
When a unit is enabled in the pre-market panel, an Anonym toggle appears
inline — hides address and property name in the matching feed, showing
only area, type and region to prospective tenants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-06-10 22:58:02 +02:00
parent ee229f8f4f
commit e64ae15d17
4 changed files with 72 additions and 17 deletions
+6 -5
View File
@@ -9,19 +9,20 @@ import { useToastStore } from '../../stores/toastStore'
import { DS_BORDER, DS_PRE_MARKET, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { SectionTitle } from './PropertyDetailHelpers'
import { PreMarketDemandIntelligence } from './PreMarketDemandIntelligence'
import { PreMarketUnitGrid } from './PreMarketUnitGrid'
import { PreMarketUnitGrid, type UnitReleaseState } from './PreMarketUnitGrid'
export const MOCK_TODAY = new Date('2026-05-20')
export function PreMarketPanel({ p }: { p: Property }) {
const [enabled, setEnabled] = useState(p.schattenmarktRelease?.enabled ?? false)
const [leadTimeMonths, setLeadTimeMonths] = useState(p.schattenmarktRelease?.leadTimeMonths ?? 6)
const [unitStates, setUnitStates] = useState<Record<string, { enabled: boolean; availableFrom: string }>>(() => {
const init: Record<string, { enabled: boolean; availableFrom: string }> = {}
const [unitStates, setUnitStates] = useState<Record<string, UnitReleaseState>>(() => {
const init: Record<string, UnitReleaseState> = {}
for (const u of p.units ?? []) {
init[u.id] = {
enabled: u.schattenmarktRelease?.enabled ?? false,
availableFrom: u.schattenmarktRelease?.availableFrom ?? u.leaseEndDate ?? '',
anonymous: u.schattenmarktRelease?.anonymous ?? false,
}
}
return init
@@ -80,11 +81,11 @@ export function PreMarketPanel({ p }: { p: Property }) {
if (enabled) save(enabled, months)
}
async function saveUnit(unitId: string, nextEnabled: boolean, nextDate: string) {
async function saveUnit(unitId: string, nextEnabled: boolean, nextDate: string, nextAnonymous: boolean) {
setUnitSaving(prev => ({ ...prev, [unitId]: true }))
try {
await MockupUnitProvider.update(unitId, {
schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined },
schattenmarktRelease: { enabled: nextEnabled, availableFrom: nextDate || undefined, anonymous: nextAnonymous },
})
await queryClient.invalidateQueries({ queryKey: ['property', p.id] })
await queryClient.invalidateQueries({ queryKey: ['properties'] })
+64 -11
View File
@@ -1,16 +1,23 @@
import { Box, CircularProgress, Switch, TextField, Typography } from '@mui/material'
import { Box, CircularProgress, Switch, TextField, Tooltip, Typography } from '@mui/material'
import { EyeOff } from 'lucide-react'
import type { Property } from '../../domain/property'
import { DS_PRE_MARKET, DS_TEXT } from '../../lib/ds'
import { floorLabel } from './PropertyDetailHelpers'
type PropertyUnit = NonNullable<Property['units']>[number]
export interface UnitReleaseState {
enabled: boolean
availableFrom: string
anonymous: boolean
}
interface Props {
units: PropertyUnit[]
unitStates: Record<string, { enabled: boolean; availableFrom: string }>
unitStates: Record<string, UnitReleaseState>
unitSaving: Record<string, boolean>
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, { enabled: boolean; availableFrom: string }>>>
saveUnit: (unitId: string, enabled: boolean, availableFrom: string) => Promise<void>
setUnitStates: React.Dispatch<React.SetStateAction<Record<string, UnitReleaseState>>>
saveUnit: (unitId: string, enabled: boolean, availableFrom: string, anonymous: boolean) => Promise<void>
}
export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates, saveUnit }: Props) {
@@ -20,40 +27,86 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
Einheiten freigeben
</Typography>
{units.map(u => {
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '' }
const us = unitStates[u.id] ?? { enabled: false, availableFrom: '', anonymous: false }
return (
<Box
key={u.id}
sx={{
display: 'grid', gridTemplateColumns: '1fr 140px auto',
gap: 1, alignItems: 'center', py: 0.75,
gap: 1, alignItems: 'start', py: 0.875,
borderBottom: '1px solid #f3e8ff',
'&:last-child': { borderBottom: 'none' },
}}
>
{/* Unit info + anonym toggle (shown when enabled) */}
<Box>
<Typography sx={{ fontSize: '0.72rem', fontWeight: 600, color: DS_TEXT.primary }}>
{floorLabel(u)}{u.unitLabel ? ` · ${u.unitLabel}` : ''}
</Typography>
<Typography sx={{ fontSize: '0.65rem', color: DS_TEXT.muted }}>
{u.areaSqm.toLocaleString('de-CH')} m²
{u.currentTenant ? ` · ${u.currentTenant}` : ''}
</Typography>
{us.enabled && (
<Tooltip title="Adresse und Objektname werden im Matching-Feed ausgeblendet — Interessenten sehen nur Fläche, Typ und Standortregion">
<Box
sx={{
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.625,
px: 0.75, py: 0.25, borderRadius: 1,
border: '1px solid',
borderColor: us.anonymous ? '#ddd6fe' : '#e2e8f0',
bgcolor: us.anonymous ? '#f5f3ff' : 'transparent',
cursor: 'pointer',
transition: 'background 0.15s, border-color 0.15s',
}}
onClick={() => {
const next = { ...us, anonymous: !us.anonymous }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, !us.anonymous)
}}
>
<EyeOff size={11} color={us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8'} />
<Typography sx={{ fontSize: '0.62rem', fontWeight: us.anonymous ? 700 : 400, color: us.anonymous ? DS_PRE_MARKET.accent : '#94a3b8', lineHeight: 1 }}>
Anonym
</Typography>
<Switch
size="small"
checked={us.anonymous}
onChange={(_, checked) => {
const next = { ...us, anonymous: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, us.enabled, us.availableFrom, checked)
}}
onClick={e => e.stopPropagation()}
sx={{
width: 28, height: 16, p: 0,
'& .MuiSwitch-switchBase': { p: '2px', '&.Mui-checked': { transform: 'translateX(12px)', color: '#fff' } },
'& .MuiSwitch-thumb': { width: 12, height: 12 },
'& .MuiSwitch-track': { borderRadius: 8, bgcolor: '#cbd5e1' },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: '#8b5cf6' },
}}
/>
</Box>
</Tooltip>
)}
</Box>
{/* Available-from date */}
<TextField
type="date"
size="small"
value={us.availableFrom}
disabled={!us.enabled}
slotProps={{ inputLabel: { shrink: true } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 } }}
sx={{ '& .MuiInputBase-input': { fontSize: '0.72rem', py: 0.5 }, mt: 0.125 }}
onChange={e => {
const next = { ...us, availableFrom: e.target.value }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
if (us.enabled) saveUnit(u.id, true, e.target.value)
if (us.enabled) saveUnit(u.id, true, e.target.value, us.anonymous)
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* Enabled toggle */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
{unitSaving[u.id] && <CircularProgress size={10} sx={{ color: DS_PRE_MARKET.accent }} />}
<Switch
size="small"
@@ -61,7 +114,7 @@ export function PreMarketUnitGrid({ units, unitStates, unitSaving, setUnitStates
onChange={(_, checked) => {
const next = { ...us, enabled: checked }
setUnitStates(prev => ({ ...prev, [u.id]: next }))
saveUnit(u.id, checked, us.availableFrom)
saveUnit(u.id, checked, us.availableFrom, us.anonymous)
}}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: DS_PRE_MARKET.accent },
+1 -1
View File
@@ -163,7 +163,7 @@ export interface Property {
importedAt?: string
lastUpdatedAt?: string
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number }
schattenmarktRelease?: { enabled: boolean; leadTimeMonths?: number; anonymous?: boolean }
status?: 'ACTIVE' | 'INACTIVE' | 'DRAFT' | 'ARCHIVED'
lastReviewedAt?: string
+1
View File
@@ -33,6 +33,7 @@ export interface PropertyUnit {
schattenmarktRelease?: {
enabled: boolean
availableFrom?: string // ISO date
anonymous?: boolean // hide address/name in pre-market feed
}
}