Add hotel discovery scrape, manual hotel add, and competitor-only filtering
- Scraper: restrict hotel-page scraper to 'competitor' tier only (was own+competitor); own hotel rates come from Newbook API, not Booking.com - Backend: POST /competitors/discover — runs search-results scrape for one date to find market hotels regardless of current backend setting - Backend: POST /competitors/hotels — add hotel manually from Booking.com URL + name + tier; upserts on slug conflict - Frontend (Settings tab): Discover Market Hotels button added below manual date-range scrape - Frontend (Hotels tab): Add Hotel form at top — paste URL (auto-derives name from slug), choose tier, submit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
30583c59a4
commit
c49985bec7
3 changed files with 287 additions and 5 deletions
|
|
@ -534,7 +534,7 @@ const SettingsTab: React.FC = () => {
|
|||
<div style={styles.card}>
|
||||
<h3 style={styles.cardTitle}>Manual Scrape</h3>
|
||||
<p style={styles.cardDescription}>
|
||||
Trigger a one-off scrape for a date range. Runs in background.
|
||||
Trigger a one-off competitor rate scrape for a date range. Runs in background.
|
||||
</p>
|
||||
<div style={styles.formRow}>
|
||||
<div style={styles.formGroup}>
|
||||
|
|
@ -579,6 +579,15 @@ const SettingsTab: React.FC = () => {
|
|||
{(scrapeMutation.error as any)?.response?.data?.detail || 'Failed to start scrape'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<hr style={{ margin: '20px 0', border: 'none', borderTop: '1px solid var(--border)' }} />
|
||||
|
||||
<h4 style={{ margin: '0 0 6px', fontSize: '14px', color: 'var(--text-dark)' }}>Discover Market Hotels</h4>
|
||||
<p style={styles.cardDescription}>
|
||||
Runs the search-results scraper for one date to find hotels in your configured location.
|
||||
Newly found hotels are added as <em>Market</em> tier so you can review and reclassify them.
|
||||
</p>
|
||||
<DiscoverButton locationConfigured={!!status?.location_configured} />
|
||||
</div>
|
||||
|
||||
{/* Scrape History */}
|
||||
|
|
@ -905,6 +914,131 @@ const ParityAlertsTab: React.FC = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const DiscoverButton: React.FC<{ locationConfigured: boolean }> = ({ locationConfigured }) => {
|
||||
const qc = useQueryClient()
|
||||
const m = useMutation({
|
||||
mutationFn: () => api.post('/competitors/discover').then(r => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['scraper-status'] })
|
||||
qc.invalidateQueries({ queryKey: ['scrape-history'] })
|
||||
},
|
||||
})
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
onClick={() => m.mutate()}
|
||||
disabled={m.isPending || !locationConfigured}
|
||||
style={{ opacity: locationConfigured ? 1 : 0.5 }}
|
||||
>
|
||||
{m.isPending ? 'Discovering…' : 'Discover Market Hotels'}
|
||||
</button>
|
||||
{!locationConfigured && <p style={styles.hintText}>Configure a location first</p>}
|
||||
{m.isSuccess && (
|
||||
<p style={{ color: 'var(--success)', fontSize: '13px', marginTop: '8px' }}>
|
||||
Discovery started — new hotels will appear in the Hotels tab as Market tier.
|
||||
</p>
|
||||
)}
|
||||
{m.isError && (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '13px', marginTop: '8px' }}>
|
||||
{(m.error as any)?.response?.data?.detail || 'Discovery failed'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const AddHotelForm: React.FC = () => {
|
||||
const qc = useQueryClient()
|
||||
const [url, setUrl] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [tier, setTier] = useState('competitor')
|
||||
|
||||
// Auto-derive a display name from the URL slug when the user pastes a URL
|
||||
const handleUrlChange = (raw: string) => {
|
||||
setUrl(raw)
|
||||
const m = raw.match(/\/hotel\/\w+\/([^.?#]+)/)
|
||||
if (m) {
|
||||
const derived = m[1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
if (!name) setName(derived)
|
||||
}
|
||||
}
|
||||
|
||||
const m = useMutation({
|
||||
mutationFn: () => api.post('/competitors/hotels', { booking_com_url: url.trim(), name: name.trim(), tier }).then(r => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['competitor-hotels'] })
|
||||
setUrl('')
|
||||
setName('')
|
||||
setTier('competitor')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div className="card-header">Add Hotel</div>
|
||||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
|
||||
Paste a Booking.com hotel page URL to add it directly without running a discovery scrape.
|
||||
</p>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||
Booking.com URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.booking.com/hotel/gb/hotel-name.en-gb.html"
|
||||
value={url}
|
||||
onChange={e => handleUrlChange(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Hotel name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||
Tier
|
||||
</label>
|
||||
<select value={tier} onChange={e => setTier(e.target.value)} style={{ width: 140 }}>
|
||||
<option value="competitor">Competitor</option>
|
||||
<option value="market">Market</option>
|
||||
<option value="own">Own Hotel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => m.mutate()}
|
||||
disabled={m.isPending || !url.trim() || !name.trim()}
|
||||
>
|
||||
{m.isPending ? 'Adding…' : 'Add Hotel'}
|
||||
</button>
|
||||
{m.isSuccess && <span style={{ fontSize: 12, color: 'var(--success)' }}>✓ Added</span>}
|
||||
{m.isError && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
{(m.error as any)?.response?.data?.detail || 'Failed to add hotel'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const HotelsTab: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [tierFilter, setTierFilter] = useState<string>('')
|
||||
|
|
@ -1016,6 +1150,8 @@ const HotelsTab: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<AddHotelForm />
|
||||
|
||||
{/* Filter */}
|
||||
<div style={styles.filterRow}>
|
||||
<span style={styles.filterLabel}>Filter:</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue