map fixes

This commit is contained in:
lovebird 2026-04-01 17:24:08 +02:00
parent 91c6412491
commit ecf2cb1836
27 changed files with 1327 additions and 1087 deletions

662
packages/ui/docs/acl.md Normal file
View File

@ -0,0 +1,662 @@
# ACL Management — Endpoint-Level, Database-Driven
> **Status**: Proposal
> **Scope**: Server-side ACL enforcement, admin CRUD, OpenAPI-first endpoint registry, product & cost metadata
> **Out of scope (for now)**: Billing/invoicing, UI
---
## 1. Problem Statement
The platform has **two disconnected permission layers** and a hardcoded product config that don't compose:
| Layer | Mechanism | Granularity |
|-------|-----------|-------------|
| `auth.ts` middleware | `PublicEndpointRegistry`, `AdminEndpointRegistry` | Binary: public vs auth vs admin |
| `db-acl.ts` orchestrator | Per-resource backends (VFS file-based, category DB-backed) | Resource-level ACL entries |
Additionally, `products.ts` hardcodes `PRODUCT_ACTIONS` with per-endpoint `costUnits` — this should move to the database so products and their endpoints are fully manageable via admin CRUD.
**What's missing:**
- No way for admins to **manage endpoint access** from a database — everything is code-deployed
- No **group/tier** concept for API endpoints (e.g. "free users get 10 searches/day, pro users get 1000")
- No standard way to express "user X can create pages but not posts" or "group Y can use the AI image generator"
- The `resource_acl` table handles per-object ACL (who can view this category) but not per-endpoint ACL (who can call POST /api/pages)
- No mechanism to feed fine-grained permissions to the frontend (e.g. "hide the Create Page button for this user")
---
## 2. Design Goals
1. **Zero new tables** — Everything fits in the existing `resource_acl` table using new `resource_type` conventions.
2. **OpenAPI-first** — The canonical list of "things you can control" is the OpenAPI spec (`/doc`). Every registered route has a `path`, `method`, and `tags[]`. ACL rules reference these directly.
3. **Database-driven** — All ACL configuration lives in Postgres, manageable via admin API. No more code deploys to change who can do what.
4. **Groups with inheritance** — ACL groups (tiers) can inherit from parent groups. `pro` inherits everything from `free`, then adds more.
5. **Rate-limit tiers** — Each group can have per-endpoint rate limits. The rate limiter middleware reads these from the ACL tables.
6. **Capability surfacing** — A single API call returns the authenticated user's effective permissions, consumable by the frontend to show/hide UI elements.
7. **Backward compatible** — Existing `PublicEndpointRegistry` / `AdminEndpointRegistry` continue to work. The new system layers on top.
---
## 3. Data Model — `resource_acl` Convention Map
### 3.0 Existing table (unchanged)
```sql
create table public.resource_acl (
id uuid primary key default gen_random_uuid(),
resource_type text not null,
resource_id text not null,
resource_owner_id uuid references auth.users(id),
user_id uuid references auth.users(id) on delete cascade,
group_name text,
permissions text[] not null default '{}',
path text default '/',
meta jsonb default '{}',
log jsonb default '{}',
created_at timestamptz default now(),
updated_at timestamptz default now(),
constraint check_grantee check (
(user_id is not null and group_name is null) or
(user_id is null and group_name is not null) or
(user_id is null and group_name is null) -- definition-only rows
)
);
```
The `check_grantee` constraint already allows rows where **both** `user_id` and `group_name` are `NULL` — these are perfect for definition/config rows (products, endpoints, groups).
### 3.1 New `resource_type` conventions
All new concepts map to `resource_type` discriminators on the **same table**:
| Concept | `resource_type` | `resource_id` | `user_id` | `group_name` | `permissions` | `path` | `meta` |
|---------|----------------|---------------|-----------|-------------|---------------|--------|--------|
| **Product → Group rule** | `product-acl` | product slug: `places` | — | `pro` | `[]` | `/` | `{effect, rate_limit, rate_window}` — applies to ALL endpoints in product |
| **Product → User override** | `product-acl` | product slug: `places` | `uuid` | — | `[]` | `/` | `{effect, rate_limit, rate_window, reason}` |
| **Endpoint def** | `endpoint` | `GET:/api/pages` | — | — | `[]` | `/api/pages` | `{tag, summary, product, cost_units, cancellable, is_public, is_admin}` |
| **Endpoint → Group rule** | `endpoint-acl` | `GET:/api/pages` | — | `editor` | `['create','update']` | `/` | `{effect, rate_limit, rate_window}` |
| **Endpoint → User override** | `endpoint-acl` | `GET:/api/pages` | `uuid` | — | `['create']` | `/` | `{effect, rate_limit, rate_window, reason, granted_by, expires_at}` |
| **Group def** | `acl-group` | slug: `editor` | — | — | `[]` | `/` | `{name, description, parent, priority, is_default}` |
| **Group member** | `acl-group-member` | group slug: `editor` | `uuid` | — | `[]` | `/` | `{granted_by, expires_at}` |
| VFS ACL (existing) | `vfs` | folder id | `uuid` or — | group or — | perms | scoped path | — |
| Category ACL (existing) | `category` | category id | `uuid` or — | group or — | perms | `/` | — |
> **Product-level vs endpoint-level rules:** A `product-acl` rule is a shorthand for "all endpoints in this product". When both exist, endpoint-level rules (`endpoint-acl`) override product-level rules (`product-acl`). This means admins can set a blanket rule for a product and then override specific endpoints.
### 3.2 Column reuse rationale
| Column | Original purpose | Reused for |
|--------|-----------------|------------|
| `resource_type` | Discriminator (`vfs`, `category`) | New types: `product-acl`, `endpoint`, `endpoint-acl`, `acl-group`, `acl-group-member` |
| `resource_id` | Resource identifier | Product slug, endpoint key (`METHOD:path`), group slug |
| `path` | VFS scoped path | Product prefix (`/api/places`), endpoint URL path |
| `permissions` | VFS/category perms | Endpoint fine-grained perms (`create`, `update`, `delete`) |
| `group_name` | VFS group grant | ACL group slug for endpoint rules |
| `user_id` | VFS user grant | Group membership, user overrides |
| `meta` | Extensible metadata | All config: costs, rate limits, tags, descriptions |
| `resource_owner_id` | VFS folder owner | Not used for new types (NULL) |
| `log` | Audit trail | Audit trail for rule changes |
### 3.3 Required schema changes
Only **one new index** needed — the existing indexes already cover the primary lookups:
```sql
-- New: fast group membership lookups (user → which groups?)
create index if not exists idx_resource_acl_group_name
on resource_acl(group_name)
where group_name is not null;
-- New: fast resource_type filtering for the new conventions
create index if not exists idx_resource_acl_type_id
on resource_acl(resource_type, resource_id)
where resource_type in ('product-acl', 'endpoint', 'endpoint-acl', 'acl-group', 'acl-group-member');
```
### 3.4 Seeding
```sql
-- Default ACL groups
insert into resource_acl (resource_type, resource_id, meta) values
('acl-group', 'anonymous', '{"name":"Anonymous","priority":0,"is_default":false}'),
('acl-group', 'authenticated', '{"name":"Authenticated","priority":10,"is_default":true}'),
('acl-group', 'editor', '{"name":"Editor","priority":20,"is_default":false,"parent":"authenticated"}'),
('acl-group', 'admin', '{"name":"Admin","priority":100,"is_default":false,"parent":"editor"}');
```
### 3.5 Example rows
```
# ── Note: Products themselves are defined in a separate `products` table ──
# e.g., A row in the 'products' table where slug='places' and settings='{"default_cost_units": 1.0}'
# ── Product-level ACL ───────────────────────────────────────
# "free" group gets access to places product, 10 calls/day across ALL its endpoints
resource_type=product-acl resource_id=places group_name=free
meta={effect:"allow", rate_limit:10, rate_window:86400}
# "pro" group gets 1000 calls/day across the whole product
resource_type=product-acl resource_id=places group_name=pro
meta={effect:"allow", rate_limit:1000, rate_window:86400}
# ── Endpoint definition (auto-synced from OpenAPI) ──────────
resource_type=endpoint resource_id=GET:/api/places/search path=/api/places/search
meta={tag:"Places", summary:"Search places", product:"places", cost_units:1.0, cancellable:true}
# ── Endpoint-level ACL override ─────────────────────────────
# Override: the find-email endpoint is more expensive, tighter limit for free
resource_type=endpoint-acl resource_id=GET:/api/places/email/:id group_name=free
meta={effect:"allow", rate_limit:3, rate_window:86400}
# ── User override ───────────────────────────────────────────
# VIP user gets 5000/day on the full product
resource_type=product-acl resource_id=places user_id=<alice-uuid>
meta={effect:"allow", rate_limit:5000, rate_window:86400, reason:"VIP customer"}
# ── Content creation example ────────────────────────────────
resource_type=endpoint-acl resource_id=POST:/api/pages group_name=editor
permissions=[create] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=PUT:/api/pages/:id group_name=editor
permissions=[update] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=DELETE:/api/pages/:id group_name=editor
permissions=[] meta={effect:"deny"}
```
### ER Diagram (logical, single table)
```mermaid
erDiagram
resource_acl {
uuid id PK
text resource_type "product-acl | endpoint | endpoint-acl | acl-group | acl-group-member | vfs | category"
text resource_id "slug or METHOD:path"
uuid resource_owner_id FK "nullable"
uuid user_id FK "nullable"
text group_name "nullable"
text[] permissions
text path "URL prefix or scoped path"
jsonb meta "all config & metadata"
jsonb log "audit trail"
}
resource_acl ||--o{ resource_acl : "group → members (via resource_id match)"
resource_acl ||--o{ resource_acl : "group → rules (via group_name match)"
resource_acl ||--o{ resource_acl : "endpoint → rules (via resource_id match)"
```
---
## 4. OpenAPI Endpoint Sync
On server boot, the system auto-syncs the OpenAPI spec into `resource_acl`:
```
Boot flow:
1. app.doc31('/doc', ...) generates the OpenAPI spec
2. syncEndpointsFromOpenAPI() reads the spec
3. For each operation:
UPSERT into resource_acl where resource_type='endpoint' and resource_id='METHOD:/path'
Sets meta: {tag, summary, is_public, is_admin, product (by longest prefix match)}
4. Mark stale endpoints (in DB but not in spec) via meta.deprecated = true
```
### Product assignment logic
During sync, each endpoint's `path` is matched against product rows in the *separate `products` table* (via prefix matching). Longest prefix wins:
```
/api/places/search → matches product 'places' (by settings prefix or path convention)
/api/images/upload → matches product 'images'
```
### Source: `PublicEndpointRegistry` + `AdminEndpointRegistry`
The existing decorators (`Public()`, `Admin()`) already populate these registries. The sync step reads both to set `meta.is_public` and `meta.is_admin` flags.
---
## 5. Permission Resolution Algorithm
When a request hits the middleware:
```
1. Extract user from JWT (or 'anonymous' sentinel)
2. Load user's groups:
SELECT resource_id FROM resource_acl
WHERE resource_type = 'acl-group-member' AND user_id = $1
UNION
SELECT resource_id FROM resource_acl
WHERE resource_type = 'acl-group' AND (meta->>'is_default')::boolean = true
→ then walk parent chain via meta.parent
3. Match endpoint → product:
a. Find resource_type='endpoint' row matching the request path+method
b. Read meta.product to get the product slug
4. Load effective rules (two tiers):
a. Endpoint-level rules:
SELECT * FROM resource_acl
WHERE resource_type = 'endpoint-acl'
AND resource_id = $endpoint_key
AND (group_name = ANY($user_groups) OR user_id = $user_id)
b. Product-level rules (fallback if no endpoint-level match):
SELECT * FROM resource_acl
WHERE resource_type = 'product-acl'
AND resource_id = $product_slug
AND (group_name = ANY($user_groups) OR user_id = $user_id)
5. Apply priority ordering:
- User overrides (user_id set) beat group rules
- Endpoint-level rules beat product-level rules
- Higher-priority groups override lower-priority groups
- Explicit 'deny' overrides 'allow' at same priority
6. Resolve cost:
endpoint.meta.cost_units ?? settings.default_cost_units (from separate products table) ?? 0
7. Resolve rate limit:
endpoint-acl rule > product-acl rule > settings.default_rate_limit (from products table)
(user override always wins over group rule at any level)
8. Return allow/deny + effective rate limit + cost_units + permissions
```
### Caching Strategy
```
Cache key: `endpoint-acl:${userId}`
TTL: 5 minutes (same as existing ACL_CACHE_TTL)
Invalidation: On membership change, rule change, or override change
```
### Query examples
```sql
-- All groups for a user (including defaults and parents)
WITH RECURSIVE user_groups AS (
-- Direct memberships
SELECT r.resource_id AS group_slug
FROM resource_acl r
WHERE r.resource_type = 'acl-group-member'
AND r.user_id = $1
AND (r.meta->>'expires_at' IS NULL OR (r.meta->>'expires_at')::timestamptz > now())
UNION
-- Default groups
SELECT r.resource_id
FROM resource_acl r
WHERE r.resource_type = 'acl-group'
AND (r.meta->>'is_default')::boolean = true
),
-- Walk parent chain
group_tree AS (
SELECT g.group_slug, g2.meta->>'parent' AS parent_slug,
(g2.meta->>'priority')::int AS priority
FROM user_groups g
JOIN resource_acl g2 ON g2.resource_type = 'acl-group'
AND g2.resource_id = g.group_slug
UNION
SELECT gt.parent_slug, g2.meta->>'parent',
(g2.meta->>'priority')::int
FROM group_tree gt
JOIN resource_acl g2 ON g2.resource_type = 'acl-group'
AND g2.resource_id = gt.parent_slug
WHERE gt.parent_slug IS NOT NULL
)
SELECT DISTINCT group_slug, priority FROM group_tree ORDER BY priority DESC;
-- Effective rules for an endpoint + user's groups
SELECT r.*, g.meta->>'priority' AS group_priority
FROM resource_acl r
LEFT JOIN resource_acl g ON g.resource_type = 'acl-group'
AND g.resource_id = r.group_name
WHERE r.resource_type = 'endpoint-acl'
AND r.resource_id = $endpoint_key
AND (r.group_name = ANY($user_group_slugs) OR r.user_id = $user_id)
ORDER BY
CASE WHEN r.user_id IS NOT NULL THEN 1000 ELSE 0 END +
COALESCE((g.meta->>'priority')::int, 0) DESC;
```
---
## 6. API Endpoints (Admin)
All under `/api/admin/acl/` — admin-only.
### 6.1 Products
Products are defined in their own `products` table. They serve as the **grouping unit** for endpoints and define:
- **defaults**: `default_cost_units`, `default_rate_limit`, `default_rate_window` (stored in the JSONB settings column, inherited by all child endpoints)
- **enabled/disabled**: disabling a product sets `settings.enabled=false` denying all its endpoints
Product-level ACL rules (`product-acl` in `resource_acl`) let admins set blanket access per group without creating N rules per endpoint.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/products` | List all products from `products` table |
| `POST` | `/api/admin/products` | Create product in `products` table |
| `PUT` | `/api/admin/products/:slug` | Update product in `products` table |
| `DELETE` | `/api/admin/products/:slug` | Delete product from `products` table |
| `GET` | `/api/admin/acl/products/:slug/rules` | List product-level ACL rules (`product-acl` in `resource_acl`) |
| `POST` | `/api/admin/acl/products/:slug/rules` | Create product-level ACL rule (group or user) |
| `DELETE` | `/api/admin/acl/products/:slug/rules/:id` | Delete product-level rule |
### 6.2 Endpoints Registry
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/endpoints` | List all endpoints (filters: tag, product, is_public, is_admin) |
| `PUT` | `/api/admin/acl/endpoints/:id` | Update endpoint (cost_units, product, cancellable) |
| `POST` | `/api/admin/acl/endpoints/sync` | Force re-sync from OpenAPI spec |
### 6.3 Groups CRUD
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/groups` | List all groups (with membership counts) |
| `POST` | `/api/admin/acl/groups` | Create group |
| `PUT` | `/api/admin/acl/groups/:slug` | Update group |
| `DELETE` | `/api/admin/acl/groups/:slug` | Delete group (cascade rules) |
### 6.4 Group Members
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/groups/:slug/members` | List members |
| `POST` | `/api/admin/acl/groups/:slug/members` | Add member(s) |
| `DELETE` | `/api/admin/acl/groups/:slug/members/:userId` | Remove member |
### 6.5 Rules
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/rules` | List all rules (filter by group, endpoint, product, tag) |
| `POST` | `/api/admin/acl/rules` | Create/update rule |
| `DELETE` | `/api/admin/acl/rules/:id` | Delete rule |
| `POST` | `/api/admin/acl/rules/batch` | Bulk create/update rules |
### 6.6 User Overrides
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/overrides/:userId` | Get overrides for user |
| `POST` | `/api/admin/acl/overrides` | Create override |
| `DELETE` | `/api/admin/acl/overrides/:id` | Delete override |
### 6.7 Capability Query (Non-Admin)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/acl/capabilities` | Returns the calling user's effective permissions across all endpoints |
Response shape:
```json
{
"groups": ["authenticated", "editor"],
"capabilities": {
"POST /api/pages": {
"allowed": true,
"permissions": ["create"],
"rateLimit": { "max": 100, "windowSec": 3600 }
},
"POST /api/posts": {
"allowed": true,
"permissions": ["create", "update"],
"rateLimit": null
},
"GET /api/competitors": {
"allowed": false,
"reason": "upgrade_required"
}
},
"tags": {
"Pages": { "create": true, "update": true, "delete": false },
"Posts": { "create": true, "update": true, "delete": true },
"AI": { "generate_image": false }
}
}
```
The `tags` summary is the **key UI bridge** — frontend can check `capabilities.tags.Pages.create` to decide whether to show the "New Page" button.
---
## 7. Middleware Integration
### New middleware: `aclMiddleware`
Inserted **after** `optionalAuthMiddleware` and **before** route handlers:
```
app.use('/api/*', optionalAuthMiddleware)
app.use('/api/*', adminMiddleware)
app.use('/api/*', aclMiddleware) // ← NEW
```
```typescript
// Pseudocode for aclMiddleware
export async function aclMiddleware(c: Context, next: Next) {
const path = c.req.path;
const method = c.req.method;
// 1. Skip for public endpoints (already handled)
if (PublicEndpointRegistry.isPublic(path, method)) {
return await next();
}
// 2. Admin bypass
if (c.get('isAdmin')) {
return await next();
}
// 3. Resolve user
const userId = c.get('userId') || 'anonymous';
// 4. Check endpoint ACL
const result = await resolveEndpointAcl(userId, path, method);
if (result.denied) {
return c.json({
error: 'Forbidden',
reason: result.reason, // 'no_permission' | 'rate_limited' | 'upgrade_required'
upgrade: result.upgradeHint // optional: which group would unlock this
}, 403);
}
// 5. Apply dynamic rate limit
if (result.rateLimit) {
const limited = await checkDynamicRateLimit(userId, path, method, result.rateLimit);
if (limited) {
return c.json({
error: 'Rate limit exceeded',
limit: result.rateLimit.max,
windowSec: result.rateLimit.windowSec,
retryAfter: limited.retryAfterSec
}, 429);
}
}
// 6. Attach capabilities to context (for downstream handlers)
c.set('aclPermissions', result.permissions);
c.set('aclGroups', result.groups);
await next();
}
```
---
## 8. Relationship to Existing Systems
### Coexistence with existing `resource_acl` usage
| `resource_type` | Controls | Example |
|-----------------|----------|---------|
| `vfs` | VFS folder access per user/group | Team can read `/shared` |
| `category` | Category visibility | Anonymous can view category X |
| `product` | **NEW:** Product definitions | Product `places` with prefix `/api/places` |
| `endpoint` | **NEW:** Endpoint registry | `GET:/api/competitors` with cost_units=1.0 |
| `acl-group` | **NEW:** Permission group defs | `editor` group with priority 20 |
| `acl-group-member` | **NEW:** User ↔ group mapping | Alice is in group `pro` |
| `endpoint-acl` | **NEW:** Endpoint permissions | Group `free` gets 10 searches/day |
All share the same table, same indexes, same `IAclBackend` pattern. The `resource_type` discriminator keeps them cleanly separated.
### Subsumes `PRODUCT_ACTIONS` (config/products.ts)
The hardcoded `PRODUCT_ACTIONS` map is **replaced** by `resource_type='product'` + `resource_type='endpoint'` rows:
| Old (`products.ts`) | New (`resource_acl`) |
|---------------------|----------------------|
| `endpoint: '/api/competitors'` | `resource_id = 'GET:/api/competitors'`, `path = '/api/competitors'` |
| `method: 'GET'` | Encoded in `resource_id` prefix |
| `costUnits: 1.0` | `meta.cost_units = 1.0` |
| `cancellable: true` | `meta.cancellable = true` |
| product key `'competitors'` | `meta.product = 'places'` → links to product row |
**Migration path:**
1. Add 2 new indexes to `resource_acl`
2. Seed `resource_type='product'` rows from `products.json` topology
3. First boot sync populates `resource_type='endpoint'` rows and auto-assigns products
4. Admin reviews and sets `meta.cost_units` for metered endpoints
5. `FunctionRegistry.findByRoute()` switches from reading `PRODUCT_ACTIONS` to querying `resource_acl` (cached)
6. `products.ts` config becomes dead code and is removed
### Migration of `PublicEndpointRegistry` / `AdminEndpointRegistry`
These become **seed data** for `resource_type='endpoint'` rows. The registries continue to function at the code level (for boot-time routing), and `syncEndpointsFromOpenAPI()` mirrors them into the database.
---
## 9. Implementation Phases
### Phase 1: Schema + Sync (Foundation)
- [ ] Add 2 new indexes to `resource_acl`
- [ ] Seed `resource_type='product'` rows from `products.json` topology
- [ ] Seed default `resource_type='acl-group'` rows (anonymous, authenticated, editor, admin)
- [ ] Write `syncEndpointsFromOpenAPI()` function (auto-assigns products by prefix)
- [ ] Run on boot after `registerProductRoutes()`
- [ ] Register new `IAclBackend` implementations: `ProductBackend`, `EndpointBackend`, `GroupBackend`
- [ ] Admin API: `GET /api/admin/acl/endpoints`, `GET /api/admin/acl/products`
- [ ] Admin API: `PUT /api/admin/acl/endpoints/:id` (set cost_units, product, cancellable)
- [ ] Migrate `FunctionRegistry.findByRoute()` to read from `resource_acl` cache
### Phase 2: Groups + Rules CRUD
- [ ] Admin API for groups, members, rules, overrides
- [ ] Zod schemas for all admin endpoints
- [ ] Group membership with expiry support
### Phase 3: Middleware Enforcement
- [ ] `aclMiddleware` with permission resolution
- [ ] Dynamic rate limiting integration
- [ ] Cache layer with invalidation hooks
### Phase 4: Capabilities API
- [ ] `GET /api/acl/capabilities` for frontend consumption
- [ ] Tag-level permission summaries
- [ ] Frontend integration (capability checks for UI gating)
---
## 10. Example Scenarios
### Scenario A: Product-level rate limiting (the common case)
Admin sets up the `places` product with tiered access — one `product-acl` rule per group covers ALL endpoints under `/api/places/*`:
```
# Groups
resource_type=acl-group resource_id=free meta={priority:10, is_default:true}
resource_type=acl-group resource_id=pro meta={priority:20, parent:"free"}
# Product definition in 'products' table via SQL or admin UI:
# INSERT INTO products (name, slug, settings) VALUES ('places', 'places', '{"enabled":true, "default_cost_units": 1.0}')
# Product-level rules (blanket for the whole product)
resource_type=product-acl resource_id=places group_name=free
meta={effect:"allow", rate_limit:10, rate_window:86400} ← 10/day
resource_type=product-acl resource_id=places group_name=pro
meta={effect:"allow", rate_limit:1000, rate_window:86400} ← 1000/day
```
Free user hits `GET /api/places/search` or `GET /api/places/details/:id` → both rate limited at 10/day total.
Pro user → 1000/day total across all places endpoints.
### Scenario B: Endpoint-level override within a product
The `find-email` endpoint under `places` is expensive. Admin adds an endpoint-level rule that overrides the product blanket:
```
# This overrides the product-acl for free users, specifically for find-email
resource_type=endpoint-acl resource_id=GET:/api/places/email/:id group_name=free
meta={effect:"allow", rate_limit:3, rate_window:86400} ← only 3/day for free
# Pro users: no endpoint override → inherits product-acl (1000/day)
```
Free user: 10/day for all places endpoints EXCEPT find-email which is 3/day.
Pro user: 1000/day for everything including find-email.
### Scenario C: Content creation permissions
```
resource_type=endpoint-acl resource_id=POST:/api/pages group_name=editor
permissions=[create] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=PUT:/api/pages/:id group_name=editor
permissions=[update] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=DELETE:/api/pages/:id group_name=editor
permissions=[] meta={effect:"deny"}
```
Frontend calls `GET /api/acl/capabilities`:
```json
{
"tags": {
"Pages": { "create": true, "update": true, "delete": false },
"Posts": { "create": true, "update": true, "delete": false }
}
}
```
→ UI hides delete buttons, shows create/edit buttons.
### Scenario D: User-specific override at product level
```
User 'alice' is in group 'free' (10 places calls/day via product-acl)
resource_type=product-acl resource_id=places user_id=<alice-uuid>
meta={effect:"allow", rate_limit:500, rate_window:86400, reason:"VIP customer"}
```
→ Alice gets 500/day on ALL places endpoints despite being in the free group.
(User overrides always win over group rules.)
---
## 11. File Structure
```
server/src/
├── middleware/
│ └── acl.ts # aclMiddleware (Phase 3)
├── products/serving/db/
│ ├── db-acl.ts # existing resource ACL orchestrator (extended with new backends)
│ ├── db-acl-db.ts # existing DB backend (unchanged)
│ ├── db-acl-vfs.ts # existing VFS backend (unchanged)
│ ├── db-endpoint-acl.ts # NEW: endpoint ACL resolution engine
│ ├── db-endpoint-acl-sync.ts # NEW: OpenAPI → resource_acl sync
│ ├── db-endpoint-acl-admin.ts # NEW: admin CRUD handlers
│ └── db-endpoint-acl-routes.ts # NEW: route definitions
```

View File

@ -1,50 +0,0 @@
# Search Algorithm Strategies
## 1. Zoom Level Calibration
**Objective**: Determine the optimal zoom level to maximize the number of unique locations found for a given keyword and location.
### Strategy
1. **Input**:
- Keyword (e.g., "carpenters")
- Location (e.g., "Barcelona, Spain")
- Zoom Range (e.g., 12-18)
2. **Process**:
- Iterate through the defined range of zoom levels.
- For each zoom level:
- Perform a Google Maps search using the `googleMaps` function.
- Store the results in a JSON file using the `--dst` option (e.g., `./tmp/search/test-zoomlevel-<zoom>.json`).
- Count the total number of valid results returned.
- Maintain a record of (Zoom Level -> Result Count).
3. **Output**:
- The zoom level that yielded the maximum number of results.
4. **Notes**:
- This process assumes that for a fixed point, the "best" zoom captures the most relevant density without being too broad (losing small entities) or too narrow (missing context).
- Overlaps/Duplicates should be handled by the underlying search function or post-processing if multi-point scanning is used later.
## 2. Area Scanning (Grid Search)
**Objective**: Scan a larger, defined area (e.g., "Madrid" or "Spain") using the optimal zoom level to ensure comprehensive coverage.
### Strategy (Planning)
1. **Input**:
- Target Area Boundaries (Polygon/Box).
- Calibrated Zoom Level (from Step 1).
- List of Provinces/Towns (if segmenting by admin regions).
2. **Grid Generation**:
- Determine the lat/long delta that corresponds to the calibrated zoom level's viewport size.
- Create a grid of search coordinates covering the Target Area.
3. **Execution & State Management**:
- This is a long-running task.
- **State Store**: Maintain a persistent state (JSON/DB) tracking:
- Queue of pending coordinates.
- Completed coordinates.
- Failed coordinates.
- Process the queue sequentially or in parallel batches.
4. **Aggregation**:
- Combine all result files.
- Perform global deduplication (by `place_id` or `title` + `address`).

View File

@ -1,114 +0,0 @@
# Enricher System Design
## Overview
We are separating the "enrichment" logic (scraping, email finding, etc.) from the core search library (`@polymech/search`) to create a modular, extensible system within the server. This system will support both on-demand discovery (fast initial results + streaming enrichment) and batch processing.
## Goals
1. **Decouple:** Move enrichment logic out of `googlemaps.ts`.
2. **Performance:** Allow fast initial search results (meta=false) with lazy loading for enrichment.
3. **Extensibility:** Registry-based system to easily swap or add enrichers (e.g., 'local', 'outsource').
4. **Streaming:** Centralized streaming hub to emit enrichment updates to the client.
## Architecture
### 1. The Enricher Interface
Each enricher must implement a standard interface.
```typescript
export interface EnrichmentContext {
userId: string;
// ... potentially other context
}
export interface IEnricher {
name: string;
type: 'meta' | 'email' | 'phones' | string;
/**
* Enrich a single location.
* @param location The partial competitor data available
* @param context Execution context
*/
enrich(location: CompetitorFull, context: EnrichmentContext): Promise<Partial<CompetitorFull>>;
}
```
### 2. Registry
A simple registry to manage available enrichers.
```typescript
export class EnricherRegistry {
private static enrichers: Map<string, IEnricher> = new Map();
static register(name: string, enricher: IEnricher) {
this.enrichers.set(name, enricher);
}
static get(name: string): IEnricher | undefined {
return this.enrichers.get(name);
}
static getAll(): IEnricher[] {
return Array.from(this.enrichers.values());
}
}
```
### 3. Implementation: 'Local' Meta Enricher
We will port the scraping logic from `search/src/lib/html.ts` to `server/src/products/locations/enrichers/local-meta.ts`.
* **Logic:** Puppeteer/Axios based scraping.
* **Target:** Updates `raw_data.meta`, and extracts social links/emails to `CompetitorSchemaFull` fields.
* **Adjustments:** Ensure strictly server-side dependencies are used and handle errors gracefully without crashing the stream.
### 4. Streaming Hub
A new endpoint `/api/competitors/enrich/stream` (or integrated into existing stream logic) that allows the client to request enrichment for specific items.
**Request:**
```json
{
"place_ids": ["..."],
"enrichers": ["meta"]
}
```
**Flow:**
1. Verify usage/credits.
2. For each `place_id`:
* Load current data.
* Run requested enrichers (concurrently or sequentially).
* Emit `enrichment-update` SSE event with the diff/new data.
* Persist updates to DB.
## Data Schema Extensions
We will extend `CompetitorSchemaFull` (via `raw_data` or explicit fields) to hold the enrichment results.
* `meta`: Object containing scraping results (title, description, og-tags).
* `social`: Standardized social profile links.
* `emails`: Discovered emails.
## Phasing
### Phase 1: Meta Enricher & Registry
* Create `EnricherRegistry`.
* Port `html.ts` to `server/src/products/locations/enrichers/meta.ts`.
* Setup the streaming endpoint for "meta" enrichment.
### Phase 2: Email Enricher
* Implement 'email' enricher (likely using existing logic or new providers).
### Phase 3: Client Integration
* Update client to fetch search results *without* meta first.
* Trigger enrichment stream for visible/requested items.

View File

@ -1,82 +0,0 @@
# GADM Integration Documentation
## Overview
We use the [GADM (Database of Global Administrative Areas)](https://gadm.org/) as our source of truth for administrative boundaries (GeoJSON). This allows us to perform "Regional Scanning" by defining precise polygons for irregular areas like cities, provinces, and states.
## Data Structure
GADM organizes areas hierarchically.
- **Level 0**: Country (e.g., `ESP` for Spain)
- **Level 1**: Primary subdivision (e.g., "Catalunya" - Region)
- **Level 2**: Secondary subdivision (e.g., "Barcelona" - Province)
- **Level 3+**: Tertiary (e.g., Municipalities)
Every area has a unique **GID** (GADM ID):
- `ESP` (Spain)
- `ESP.5_1` (Catalunya)
- `ESP.5.1_1` (Barcelona)
> **Note**: GADM codes are *not* standard ISO codes. Always rely on **Name Search** to find the correct GID.
---
## API Endpoints
We expose a set of public endpoints to interface with the local `pygadm` wrapper.
### 1. Search Regions
Search for a region by name to find its metadata (GID, Name, Type) or full geometry.
`GET /api/regions/search`
| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `query` | string | Yes | Name to search for (e.g., "France", "Paris") |
| `content_level` | int | No | Filter by admin level (e.g., `1` for regions) |
| `geojson` | boolean | No | If `true`, returns full `FeatureCollection` with geometry. |
**Example:**
`/api/regions/search?query=Catalunya&content_level=1`
### 2. Get Boundary
Retrieve the precise GeoJSON boundary for a specific known GID.
`GET /api/regions/boundary/{id}`
| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `id` | string | Yes | GADM ID (e.g., `FRA.1_1`) |
**Response:**
Returns a GeoJSON `FeatureCollection` containing the polygon(s) for that region.
### 3. Get Sub-Region Names
List all child regions for a given parent code. Useful for cascading dropdowns.
`GET /api/regions/names`
| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `admin` | string | Yes | Parent Admin Code (e.g., `FRA` or `FRA.1_1`) |
| `content_level` | int | Yes | The target level to retrieve (e.g., `2`) |
---
## Mapping Strategy: SerpAPI to GADM
External data sources like Google Maps (via SerpAPI) often use different standards (ISO-3166-2) than GADM. **Do not try to map by Code.**
**Recommended Workflow:**
1. **Extract Name**: Get the administrative name from the external result (e.g., `geo.principalSubdivision` -> "Catalunya").
2. **Search GADM**: Search for this name using the endpoint.
- `GET /api/regions/search?query=Catalunya`
3. **Filter Results**:
- Match `GID_0` to the known Country Code (e.g., `ESP`) to resolve ambiguity (e.g., "Valencia" exists in Spain and Venezuela).
4. **Get Boundary**: Use the resulting `GID` (e.g., `ESP.5_1`) to fetch the polygon.

View File

@ -1,268 +0,0 @@
# Grid Search — Implementation Plan
## Core Insight
The grid search is a **GADM tree walk** with a **pluggable iterator**.
Two distinct phases:
1. **Enumerate** — walk the GADM tree to the target level → return area names + centers (free, cached)
2. **Search** — plug in an iterator function (Google Maps, email, …) that runs per area (costs credits)
The user sees the area list first. Only when they confirm, the iterator runs. Results are cached per area — re-walking skips already-searched areas.
---
## Data Flow
```
Phase 1: Enumerate (no cost) Phase 2: Search (iterator)
──────────────────────────── ─────────────────────────
region: "Spain, Catalonia" iterator: googleMapsSearch
level: "cities" types: ["machine shop"]
┌─ searchRegions("Catalonia") for each area in areas:
│ → GID = "ESP.6_1" (L1) searchGoogleMap(
│ type,
├─ getRegionNames(admin=GID, contentLevel=3) @center.lat,lon,14z
│ → ["Barcelona", "Terrassa", "Sabadell", …] )
│ → results[]
├─ getBoundaryFromGpkg(GID, 3)
│ → GeoJSON per city deduplicate by place_id
│ cache per area GID+type
└─ centroid(bbox) → { lat, lon }
→ GridArea[]
Return to user: "Found 42 cities in Catalonia.
Run location search?" → user confirms → Phase 2
```
---
## Level Mapping
| User Level | GADM | Typical Meaning |
|-------------|------|-----------------------------|
| `provinces` | 1 | State / Province / Region |
| `districts` | 2 | County / District / Kreis |
| `cities` | 3 | Municipality / City |
| `towns` | 4 | Town / Commune |
| `villages` | 5 | Village / Sub-commune |
> Not all countries have all levels. Function caps at the country's max depth.
---
## API Design
### Types
```typescript
// server/src/products/locations/gridsearch-googlemaps.ts
/** A resolved area from the GADM tree walk */
export interface GridArea {
name: string;
gid: string;
level: number;
center: { lat: number; lon: number };
}
/** Phase 1 result — just the enumerated areas */
export interface GridEnumerateResult {
region: { name: string; gid: string; level: number };
areas: GridArea[];
maxLevelAvailable: number;
}
/** Phase 2 result — per-area search output */
export interface GridSearchAreaResult {
area: GridArea;
results: any[];
cached: boolean;
error?: string;
}
/** Phase 2 full result */
export interface GridSearchResult {
region: { name: string; gid: string; level: number };
areaCount: number;
totalResults: number;
results: any[]; // deduplicated
areas: GridSearchAreaResult[];
durationMs: number;
}
```
### Functions
```typescript
/**
* Phase 1: Walk GADM tree, enumerate areas at target level.
* No SerpAPI calls, no cost. Results are cached.
*/
export async function gridEnumerate(opts: {
region: string; // "Spain, Catalonia"
level: GridLevel; // 'cities' | 'towns' | number
}): Promise<GridEnumerateResult>
/**
* Phase 2: Run Google Maps search on each area.
* This is the iterator — pluggable per search type.
*/
export async function gridSearchGoogleMaps(opts: {
areas: GridArea[];
types: string[];
apiKey: string;
bigdata?: { key: string };
limitPerArea?: number; // default: 20
zoom?: number; // default: 14
concurrency?: number; // default: 2
}): Promise<GridSearchResult>
```
### Separation of Concerns
```
gridEnumerate() → pure GADM, no cost, cacheable
gridSearchGoogleMaps() → takes areas[], fires SerpAPI, costs credits
```
Later iterators can follow the same pattern:
- `gridSearchEmails(areas, opts)` — find emails per area
- `gridSearchEnrich(areas, opts)` — run enrichers per area
---
## Step-by-Step Logic
### `gridEnumerate()`
1. Split `region` on comma: `["Spain", "Catalonia"]`
2. Last part = `name`, first = `country` hint
3. `searchRegions({ query: name, country })` → get GID + level
4. Map level label → number (`cities=3`)
5. `getRegionNames({ admin: gid, contentLevel })` → rows with NAME/GID
6. `getBoundaryFromGpkg(gid, contentLevel)` → GeoJSON features
7. Compute bbox centroid per feature → `GridArea[]`
### `gridSearchGoogleMaps()`
1. For each area, build `searchCoord: @lat,lon,{zoom}z`
2. For each type in `types[]`:
- `searchGoogleMap(type, apiKey, opts)` → results
3. Merge results per area
4. Deduplicate globally by `place_id`
5. Return `GridSearchResult`
---
## Center Computation (No Turf.js)
```typescript
function bboxCentroid(feature: any): { lat: number; lon: number } {
let minLat = Infinity, maxLat = -Infinity;
let minLon = Infinity, maxLon = -Infinity;
const walk = (coords: any) => {
if (typeof coords[0] === 'number') {
const [lon, lat] = coords;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
return;
}
for (const c of coords) walk(c);
};
walk(feature.geometry.coordinates);
return { lat: (minLat + maxLat) / 2, lon: (minLon + maxLon) / 2 };
}
```
---
## File Structure
```
server/src/products/locations/
├── gridsearch-googlemaps.ts # [NEW] gridEnumerate + gridSearchGoogleMaps
├── __tests__/
│ └── gridsearch-googlemaps.e2e.test.ts # [NEW] E2E tests
```
---
## Test Plan
```typescript
describe('Grid Search', () => {
describe('Phase 1 — Enumerate', () => {
it('enumerates Catalonia cities', async () => {
const result = await gridEnumerate({
region: 'Spain, Catalonia',
level: 'cities',
});
expect(result.areas.length).toBeGreaterThan(0);
expect(result.areas[0].center.lat).toBeTypeOf('number');
// No SerpAPI calls, no cost
});
it('enumerates Sachsen districts', async () => {
const result = await gridEnumerate({
region: 'Germany, Sachsen',
level: 'districts',
});
expect(result.areas.length).toBe(13);
});
});
describe('Phase 2 — Google Maps Search', () => {
it('searches machine shops in 2 Catalonia cities', async () => {
const enumResult = await gridEnumerate({
region: 'Spain, Catalonia',
level: 'cities',
});
// Only search first 2 areas to keep test cheap
const result = await gridSearchGoogleMaps({
areas: enumResult.areas.slice(0, 2),
types: ['machine shop'],
apiKey: config.serpapi.key,
bigdata: config.bigdata,
limitPerArea: 5,
concurrency: 1,
});
expect(result.totalResults).toBeGreaterThan(0);
expect(result.areas[0].area.name).toBeDefined();
}, 120_000);
});
});
```
### NPM Script
```json
"test:products:locations:gridsearch:googlemaps": "vitest run src/products/locations/__tests__/gridsearch-googlemaps.e2e.test.ts"
```
---
## Caching Strategy
| What | Cache Key | Storage |
|------|-----------|---------|
| Tree enumerate | `grid_enum_{gid}_{level}` | GADM file cache |
| Google Maps search | `grid_search_{gid}_{type}_{zoom}` | Supabase `place_searches` |
| Area boundaries | `boundary_{gid}` | GADM file cache (already cached) |
---
## Future Iterators (Not Phase 1)
- `gridSearchEmails(areas)` — find emails for businesses found in each area
- `gridSearchEnrich(areas)` — run meta/social enrichers per area
- PgBoss campaign integration — one child job per area
- SSE streaming — live progress as each area completes
- Cost estimation pre-flight — `areaCount × costPerSearch`

View File

@ -1,163 +0,0 @@
# Grid Search / Regional Scanning Documentation
## Overview
The Grid Search (or Regional Scanning) feature automates the discovery of leads across large, irregular geographic areas (e.g., entire cities, provinces, or countries). Instead of manual point searches, users select a defined administrative region, and the system intelligently decomposes it into a grid of optimal search points.
This functionality relies on a microservice architecture where **GADM** (Global Administrative Areas) data provides high-fidelity GeoJSON boundaries for exclusion/inclusion logic.
---
## Conceptual Architecture
### 1. Region Selection (Client)
The user select a target region (e.g., "Île-de-France, France"). The client fetches the corresponding boundary polygon from the GADM microservice (Admin Level 1/2).
### 2. Grid Decomposition (Server/Client)
The system calculates a "Search Grid" overlaying the target polygon.
- **Viewport Normalization**: A single API search at Zoom Level 15 covers roughly a 2-5km radius.
- **Bounding Box**: A rectangular grid is generated covering the polygon's extents.
- **Point-in-Polygon Filtering**: Grid centers falling *outside* the actual administrative boundary (e.g., ocean, neighboring states) are discarded using spatial analysis libraries (e.g., `Turf.js`).
### 3. Campaign Orchestration (Server)
The resulting set of valid coordinates (e.g., 450 points) is submitted as a **"Scan Campaign"**.
- **Batching**: The server does NOT run 450 searches instantly. It uses `PgBoss` to queue them as individual jobs.
- **Concurrency**: Jobs are processed with strict rate-limiting to respect SerpAPI quotas.
- **Deduplication**: Results from overlapping grid circles are merged by `place_id`.
---
## Workflow Implementation
### Step 1: User Selects Region
User interactions with the new "Region Search" UI:
1. **Search**: "California"
2. **Dropdown**: Selects "California, USA (State/Province)"
3. **Preview**: Map validates the polygon overlay.
### Step 2: Grid Generation Status
Pre-flight check displayed to user:
- **Total Area**: 423,970 km²
- **Grid Density**: High (Zoom 15)
- **Estimated Points**: ~8,500 scans (Warn: Expensive!)
- **Cost**: 8,500 Credits
- **Action**: "Confirm & Start Campaign"
### Step 3: Campaign Execution
Server receives payload:
```json
{
"regionId": "USA.5_1",
"query": "Plumbers",
"gridConfig": { "zoom": 15, "overlap": 0.2 }
}
```
Server decomposes to jobs `[Job_1, Job_2, ... Job_8500]`.
### Step 4: Live Updates
The existing SSE stream (`stream-sse`) adapts to listen for Campaign Events, updating a global progress bar:
- "Scanned 120/8500 sectors..."
- "Found 45 new leads..."
---
## Implementation TODO List
### Server-Side (`test/server`)
- [x] **GADM Integration Endpoint**:
- [x] Create route `GET /api/regions/search?q={name}` to proxy requests to the GADM microservice or query local PostGIS.
- [x] Create route `GET /api/regions/boundary/{gadm_id}` to retrieve full GeoJSON.
- [x] Create route `GET /api/regions/names?admin={code}` to fetch sub-region names.
- [ ] **Grid Logic**:
- Install `@turf/turf` for geospatial operations.
- Implement `generateGrid(boundaryFeature, zoomLevel)` function:
- Calculate `bbox`.
- Generate point grid.
- Filter `pointsWithinPolygon`.
- [ ] **Campaign Manager**:
- Create `CampaignsProduct` or extend `LocationsProduct`.
- New Job Type: `REGION_SCAN_PARENT` (decomposes into child jobs).
- New Job Type: `REGION_SCAN_CHILD` (actual search).
- [ ] **Job Queue Optimization**:
- Ensure `PgBoss` allows huge batch insertions (thousands of jobs).
- Implement "Campaign Cancellation" (kill switch for all child jobs).
### Client-Side (`test/client`)
- [ ] **Region Picker UI**:
- New Autocomplete component fetching from `/api/regions/search`.
- [ ] **Map Visualization**:
- Render the GeoJSON `Polygon` on MapLibre.
- Render the calculated `Point` grid pre-flight (allow user to manually deselect points?).
- [ ] **Campaign Dashboard**:
- New View: "Active Scans".
- Progress bars per campaign.
- "Pause/Resume" controls.
- [ ] **Result Merging**:
- Ensure the client DataGrid can handle streaming results effectively from potentially thousands of searches (Virtualization required).
---
## Existing Endpoint Reference
*(Ref. `src/products/locations/index.ts`)*
The current `LocationsProduct` is well-poised to be the parent of this logic.
- **`handleStreamGet`**: Can be adapted to accept a `campaignId` instead of a single `location`.
- **`handleStreamEmail`**: Shows the pattern for batch processing (accepting arrays of IDs). We can replicate this "Scatter-Gather" pattern for the Region Scan.
### Proposed GeoJSON Microservice Interface
We assume the existence of an internal service (or creating a dedicated module) exposing:
- `GET /gadm/v1/search?text=...` -> Returns lightweight metadata (ID, Name, Level).
- `GET /gadm/v1/feature/{id}` -> Returns heavy GeoJSON Geometry.
---
## 4. Potential Data Enrichments
To increase the value of harvested locations, the following layers can be overlaid or merged with the search results:
### Demographics & Population
- **WorldPop**: High-resolution raster data for estimating the catchment population of a specific business location.
- **Census Data**: (US Census / Eurostat) Admin-level statistics on income, age, and household size to score "Market Viability".
### Firmographics & Business Intel
- **OpenCorporates**: Verify legal entity status and official registration dates.
- **LinkedIn Organization API**: Enrich with employee count, industry tags, and recent growth signals.
- **Clearbit / Apollo.io**: Deep profile matching to find technographics (what software they use) and key decision-maker contacts.
### Environmental & Infrastructure
- **OpenStreetMap (OSM)**: Calculate "Footfall Potential" by analyzing proximity to transit hubs, parking, and density of other retail POIs.
- **WalkScore / TransitScore**: Rate the accessibility of consumer-facing businesses.
### Industry Specifics
- **TripAdvisor / Yelp**: Cross-reference hospitality ratings to find discrepancies or opportunities (e.g., highly rated on Google, poorly rated on Yelp).
- **Plastics Industry Databases**: (Specific to Polymech) Cross-referencing registered recyclers lists provided by regional environmental agencies.
<https://pygadm.readthedocs.io/en/latest/usage.html>

View File

@ -1,54 +0,0 @@
# GADM Picker Implementation details
This document covers the architectural and interaction details of the global bounds and region picker system.
The system connects an interactive `<GadmPicker />` frontend map down to a PostGIS + Martin + PMTiles mapping backend.
## Architecture & Paths
- **Main Component**: [`GadmPicker.tsx`](../../src/modules/places/gadm-picker/GadmPicker.tsx)
- **Local Searches & IO**: [`client-searches.ts`](../../src/modules/places/gadm-picker/client-searches.ts)
- **Server Application (Vite / Express)**: [`server.ts`](../../packages/gadm/server.ts)
## API Endpoints (`/api/gadm/*`)
The picker orchestrates several custom endpoints for real-time geographic data validation, mostly routed through Express in `packages/gadm/server.ts`:
- **`GET /search?q={query}&level={level}`**
Searches the PostGIS database `gadm` view for any names matching the search vector. Often utilizes Redis caching to speed up autocomplete responses.
- **`GET /hierarchy?lat={lat}&lng={lng}`**
Triggers a point-based intersection against the `gadm` multi-polygons (`ST_Intersects`). Returns the full hierarchy (Level 0 through 5) containing the given coordinate.
- **`GET /boundary?gid={gid}&targetLevel={level}&enrich={bool}`**
Returns the exact geographic boundaries of a target region as a GeoJSON FeatureCollection.
- To maintain UI performance on large sets, queries the requested `targetLevel` limit to simplify rendering visually.
- Can optionally `enrich` the returned properties with Population and Area sizing dynamically from the PG backend.
- GeoJSON responses are statically cached locally to `packages/gadm/data/boundaries/` to ensure lightning-fast subsequent fetches.
## The Map Inspector
The map view uses MapLibre GL JS pointing to a local Martin vector tile server serving `.pmtiles`.
- **Point Queries**: Clicking anywhere on the unselected tiles translates the event into a `lat, lng` inspection.
- **Hierarchy Render**: This invokes `/hierarchy` and generates a list of administrative boundaries encompassing that specific point (from Nation down to County).
- **Highlighting**: Hovering over any inferred hierarchy option loads its bounding box dynamically (`gadm-picker-highlight`) to review before formally "adding".
## Selection Lifecycle
The selection state `selectedRegions` tracks picked regions across the UI. Due to API speeds and GeoJSON size considerations, the component features a highly customized, safe, interruptible multi-selection architecture:
- **Single Select (Default)**
Clicking an autocomplete result or clicking to inspect on the map triggers a single-select wipe. This safely terminates any currently loading polygons, cancels queued network requests, and instantly drops all existing items from the array to maintain focus down to a single element.
- **Multi-Select Queue (`ctrl + click` / `⌘ + click`)**
If the `ctrl` key is held either on the autocomplete result, the inspector UI "Add" button, or on raw Map inspection clicks, the interactions skip cancellation logic. They are placed into a `queuedInspectionsRef` Set. Network resolutions occur concurrently and stack natively into the interface.
- **Import / Export Portability**
The `<GadmPicker />` exposes IO tools to manage large or heavily tailored multi-select combinations:
- **Copy Config**: Translates the active GIDs and target levels directly to the local clipboard.
- **Export JSON**: Creates a local Blob URL and downloads the `selectedRegions` metadata explicitly (excludes raw poly-data to maintain strict minimalist file sizes).
- **Import JSON**: Triggers a hidden file input `<input type="file" />`. Firing an import automatically wipes the active UI state and iteratively pushes all imported regions into the `ctrl+click` style high-speed multi-select queue to render perfectly.
## Boundaries (`setGeojsons`)
After an entity enters `selectedRegions`, its exact representation is rendered securely on the map with the ID layer `gadm-picker-features`.
- A background `useEffect` strictly manages sync loops formatting the multiple separate boundaries into a unified `FeatureCollection`, updating the MapLibre source in real-time.
- Regions feature small layout indicators (e.g. `L0`, `L2`) mapping directly to the `targetLevel` rendering logic determining boundary complexity.

View File

@ -0,0 +1,52 @@
# Products CRUD Implementation Plan
Based on the simplified approach, **Products are separate from the core `resource_acl` ecosystem**. Products will have their own dedicated table and CRUD operations. They integrate with the ACL system purely by convention (the product's `slug` is used as the `resource_id` in `resource_acl` for product-level rules).
## 1. Database Schema
We need to modify the existing `products` table (or create it if it's currently unused) to support the required fields: `id` (UUID), `name`, `slug`, and `settings` (JSONB).
- [ ] **Migration / SQL Update**:
- Convert `id` to `UUID` (or ensure new table uses UUID).
- Add `settings` `jsonb` column for flags like `{ enabled: true, ... }`.
- Retain `slug` (unique) to use as the join key for ACL mapping.
- Retain `name` and `description`.
```sql
create table if not exists public.products (
id uuid not null default gen_random_uuid (),
name text not null,
slug text not null,
description text null,
settings jsonb null default '{"enabled": true}'::jsonb,
created_at timestamp with time zone not null default now(),
updated_at timestamp with time zone not null default now(),
constraint products_pkey primary key (id),
constraint products_slug_key unique (slug)
);
```
## 2. API Endpoints (`/api/admin/products`)
Since Products are independent of the ACL internals, they get their own dedicated admin endpoints rather than being bundled under `/api/admin/acl`.
- [ ] **Zod Schemas** (`server/src/endpoints/admin-products.ts`)
- `ProductCreateSchema`: name, slug, description, settings.
- `ProductUpdateSchema`: name, description, settings (maybe allow slug updates with a warning about ACL disconnections).
- [ ] **Handlers**
- `GET /api/admin/products`: List all products.
- `GET /api/admin/products/:slug`: Get details for a single product.
- `POST /api/admin/products`: Insert new product. (Slug can be auto-generated from name if omitted).
- `PUT /api/admin/products/:slug`: Update the product row.
- `DELETE /api/admin/products/:slug`: Delete the product.
## 3. Integration with ACLs
The only touchpoint between the `products` table and the `resource_acl` table is the **slug**.
- [ ] When fetching a User's product-level permissions, we query `resource_acl` where `resource_type = 'product-acl'` and `resource_id = product.slug`.
- [ ] Optionally, implement a cleanup hook: if a product is deleted via `DELETE /api/admin/products/:slug`, fire an event to delete matching `product-acl` rows from `resource_acl`.
- [ ] During the OpenAPI sync (which we discussed for Endpoints), endpoints will match the `products.slug` to populate their `meta.product`.
## 4. Server Integration
- [ ] Build the routes in `server/src/endpoints/admin-products.ts`.
- [ ] Protect all routes with the `Admin()` decorator.
- [ ] Register the routes in the main server router.

View File

@ -0,0 +1,69 @@
create table public.products (
id bigint generated by default as identity not null,
name text not null,
slug text not null,
description text null,
price numeric(10, 2) not null default 0,
variants jsonb null default '[]'::jsonb,
created_at timestamp with time zone not null default timezone ('utc'::text, now()),
updated_at timestamp with time zone not null default timezone ('utc'::text, now()),
constraint products_pkey primary key (id),
constraint products_slug_key unique (slug)
) TABLESPACE pg_default;
create table public.product_variants (
id bigint generated by default as identity not null,
product_id bigint not null,
name text not null,
price numeric(10, 2) not null default 0,
created_at timestamp with time zone not null default timezone ('utc'::text, now()),
updated_at timestamp with time zone not null default timezone ('utc'::text, now()),
constraint product_variants_pkey primary key (id),
constraint product_variants_product_id_fkey foreign key (product_id) references products(id) on delete cascade
) TABLESPACE pg_default;
===================================
//current map
create table public.resource_acl (
id uuid not null default gen_random_uuid (),
resource_type text not null,
resource_id text not null,
resource_owner_id uuid null,
user_id uuid null,
group_name text null,
permissions text[] not null default '{}'::text[],
path text null default '/'::text,
meta jsonb null default '{}'::jsonb,
log jsonb null default '{}'::jsonb,
created_at timestamp with time zone null default now(),
updated_at timestamp with time zone null default now(),
constraint resource_acl_pkey primary key (id),
constraint resource_acl_resource_owner_id_fkey foreign KEY (resource_owner_id) references auth.users (id),
constraint resource_acl_user_id_fkey foreign KEY (user_id) references auth.users (id) on delete CASCADE,
constraint check_grantee check (
(
(
(user_id is not null)
and (group_name is null)
)
or (
(user_id is null)
and (group_name is not null)
)
or (
(user_id is null)
and (group_name is null)
)
)
)
) TABLESPACE pg_default;
create index IF not exists idx_resource_acl_lookup on public.resource_acl using btree (resource_type, resource_id) TABLESPACE pg_default;
create index IF not exists idx_resource_acl_user on public.resource_acl using btree (user_id) TABLESPACE pg_default;
create index IF not exists idx_resource_acl_owner on public.resource_acl using btree (resource_owner_id) TABLESPACE pg_default;

View File

@ -6,7 +6,7 @@ import { extendZodWithOpenApi } from '@hono/zod-openapi';
extendZodWithOpenApi(z);
export const CompetitorSchema = z.object({
export const PlaceSchema = z.object({
place_id: z.string(),
title: z.string(),
description: z.string().optional().nullable(),
@ -209,7 +209,7 @@ export const GoogleMediaSchema = z.object({
})
// Raw data schema
export const LocationSchema = z.object({
export const PlaceRawSchema = z.object({
position: z.number(),
rating: z.number(),
reviews: z.number(),
@ -243,7 +243,7 @@ export const LocationSchema = z.object({
}).partial()
// Main CompetitorSchemaFull
export const CompetitorSchemaFull = z.object({
export const PlaceSchemaFull = z.object({
place_id: z.string(),
title: z.string(),
address: z.string().optional().nullable(),
@ -253,7 +253,7 @@ export const CompetitorSchemaFull = z.object({
operating_hours: OperatingHoursSchema.optional().nullable(),
thumbnail: z.string().optional().nullable(),
types: z.array(z.string()).optional().nullable(),
raw_data: LocationSchema.optional().nullable(),
raw_data: PlaceRawSchema.optional().nullable(),
sites: z.array(z.object({
name: z.string(),
url: z.string(),
@ -266,27 +266,27 @@ export const CompetitorSchemaFull = z.object({
})
export const CompetitorResponseSchema = z.object({
export const PlaceResponseSchema = z.object({
message: z.string(),
data: z.array(CompetitorSchemaFull).optional(),
data: z.array(PlaceSchemaFull).optional(),
})
export const CompetitorDetailResponseSchema = z.object({
export const PlaceDetailResponseSchema = z.object({
message: z.string(),
data: CompetitorSchemaFull.optional(),
data: PlaceSchemaFull.optional(),
})
export type Competitor = z.infer<typeof CompetitorSchema>;
export type CompetitorResponse = z.infer<typeof CompetitorResponseSchema>;
export type CompetitorDetailResponse = z.infer<typeof CompetitorDetailResponseSchema>;
export type CompetitorFull = z.infer<typeof CompetitorSchemaFull>;
export type Competitor = z.infer<typeof PlaceSchema>;
export type PlaceResponse = z.infer<typeof PlaceResponseSchema>;
export type PlaceDetailResponse = z.infer<typeof PlaceDetailResponseSchema>;
export type PlaceFull = z.infer<typeof PlaceSchemaFull>;
export type OptionsSchemaMeta = Record<string, unknown>
let schemaMap: ZodMetaMap<OptionsSchemaMeta>;
export const CompetitorRequestSchemaMap = () => {
export const PlaceRequestSchemaMap = () => {
schemaMap = ZodMetaMap.create<OptionsSchemaMeta>()
schemaMap.add(
'location',
@ -332,8 +332,8 @@ export const CompetitorRequestSchemaMap = () => {
return schemaMap;
}
export const CompetitorRequestSchema = CompetitorRequestSchemaMap().root() as any;
export const CompetitorUISchema = CompetitorRequestSchemaMap().getUISchema();
export type LocationType = z.infer<typeof LocationSchema>;
export type CompetitorRequest = z.infer<typeof CompetitorRequestSchema>;
export const PlaceRequestSchema = PlaceRequestSchemaMap().root() as any;
export const PlaceUISchema = PlaceRequestSchemaMap().getUISchema();
export type LocationType = z.infer<typeof PlaceRawSchema>;
export type PlaceRequest = z.infer<typeof PlaceRequestSchema>;

View File

@ -1,10 +1,12 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { CompetitorsMapView } from '@/modules/places/CompetitorsMapView';
import React, { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useRef } from 'react';
import { PlacesMapView } from '@/modules/places/PlacesMapView';
import { LocationDetailView } from '@/modules/places/LocationDetail';
import { T } from '@/i18n';
import { Map as MapIcon, Loader2, Search, GripVertical } from 'lucide-react';
import { type CompetitorFull } from '@polymech/shared';
import { fetchPlacesGridSearchById } from '@/modules/places/client-gridsearch';
import { Loader2, GripVertical } from 'lucide-react';
import { usePlacesMapData, type PlacesMapWidgetHandle } from './useCompetitorsMapData';
// Re-export the handle type so consumers can import from the widget file
export type { PlacesMapWidgetHandle as CompetitorsMapWidgetHandle } from './useCompetitorsMapData';
interface CompetitorsMapWidgetProps {
isEditMode?: boolean;
@ -25,9 +27,12 @@ interface CompetitorsMapWidgetProps {
showLocations?: boolean;
posterMode?: boolean;
enableLocationDetails?: boolean;
maxHeight?: string;
/** Fired once the widget is ready with its imperative API handle. */
onReady?: (handle: PlacesMapWidgetHandle) => void;
}
const CompetitorsMapWidget: React.FC<CompetitorsMapWidgetProps> = ({
const CompetitorsMapWidget = forwardRef<PlacesMapWidgetHandle, CompetitorsMapWidgetProps>(({
isEditMode = false,
jobId,
enableSimulator = true,
@ -41,17 +46,31 @@ const CompetitorsMapWidget: React.FC<CompetitorsMapWidgetProps> = ({
showLocations = true,
posterMode = false,
enableLocationDetails = true,
}) => {
const [competitors, setCompetitors] = useState<CompetitorFull[]>([]);
const [simulatorSettings, setSimulatorSettings] = useState<any>(null);
const [fetchedRegions, setFetchedRegions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
maxHeight = '60vh',
onReady,
}, ref) => {
// -----------------------------------------------------------------------
// Data layer (hook)
// -----------------------------------------------------------------------
const {
competitors,
loading,
initialCenter,
finalGadmRegions,
initialSimulatorSettings,
handle,
handleMapReady: hookMapReady,
} = usePlacesMapData({ jobId, targetLocation });
// Location detail sidebar state
useImperativeHandle(ref, () => handle, [handle]);
// -----------------------------------------------------------------------
// UI-only state: selection sidebar
// -----------------------------------------------------------------------
const [selectedPlaceId, setSelectedPlaceId] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(340);
const containerRef = useRef<HTMLDivElement>(null);
const activeCompetitor = useMemo(() => {
if (!selectedPlaceId) return null;
@ -83,91 +102,36 @@ const CompetitorsMapWidget: React.FC<CompetitorsMapWidgetProps> = ({
window.addEventListener('mouseup', onUp);
}, [sidebarWidth]);
// Prioritize the jobId from the specialized picker over the direct prop
const effectiveJobId = targetLocation?.jobId || jobId;
// -----------------------------------------------------------------------
// Map ready: bridge hook + onReady callback
// -----------------------------------------------------------------------
const onReadyRef = useRef(onReady);
onReadyRef.current = onReady;
// Initial map values from the picker data
const initialCenter = targetLocation?.lat && targetLocation?.lng
? { lat: targetLocation.lat, lng: targetLocation.lng }
: (targetLocation?.center ? { lat: targetLocation.center.lat, lng: targetLocation.center.lng } : undefined);
const handleMapReady = useCallback((m: import('maplibre-gl').Map) => {
hookMapReady(m);
onReadyRef.current?.(handle);
}, [hookMapReady, handle]);
// GADM regions from the picker
const initialGadmRegions = (targetLocation?.gadmRegions?.length ? targetLocation.gadmRegions : fetchedRegions).map((r: any) => ({
gid: r.gid,
name: r.gadmName || r.name,
level: r.level !== undefined ? (typeof r.level === 'string' ? parseInt(r.level.replace(/\D/g, '')) : r.level) : 0
}));
// -----------------------------------------------------------------------
// Stubs
// -----------------------------------------------------------------------
const dummyEnrich = useCallback(async () => { }, []);
const handleMapCenterUpdate = useCallback(() => { }, []);
const finalGadmRegions = initialGadmRegions.length > 0
? initialGadmRegions
: (targetLocation?.gid ? [{ gid: targetLocation.gid, name: targetLocation.label || targetLocation.name, level: targetLocation.level }] : undefined);
const initialSimulatorSettings = targetLocation?.simulatorSettings || simulatorSettings;
// Fetch real data when a Job ID is available
useEffect(() => {
if (!effectiveJobId) {
setCompetitors([]);
setSimulatorSettings(null);
return;
}
if (targetLocation?.jobId === effectiveJobId && targetLocation?.competitors && targetLocation?.simulatorSettings) {
setCompetitors(targetLocation.competitors);
setSimulatorSettings(targetLocation.simulatorSettings);
return;
}
let isMounted = true;
(async () => {
setLoading(true);
try {
const job = await fetchPlacesGridSearchById(effectiveJobId);
const data = job?.data || job;
if (isMounted && data) {
const enrichResults = data.result?.enrichResults;
if (Array.isArray(enrichResults)) {
setCompetitors(enrichResults);
} else if (Array.isArray(data.places)) {
setCompetitors(data.places);
}
if (data.request?.guided?.settings) {
setSimulatorSettings(data.request.guided.settings);
}
if (Array.isArray(data.areas)) {
setFetchedRegions(data.areas);
} else if (data.request?.guided?.areas) {
setFetchedRegions(data.request.guided.areas);
}
}
} catch (err) {
console.error('Failed to load search job points:', err);
if (isMounted) setCompetitors([]);
} finally {
if (isMounted) setLoading(false);
}
})();
return () => { isMounted = false; };
}, [effectiveJobId]);
const dummyEnrich = async () => { };
const handleMapCenterUpdate = () => { };
const effectiveMaxHeight = showDetails && activeCompetitor ? '60vh' : maxHeight;
return (
<div ref={containerRef} className="w-full h-full flex flex-col bg-card border rounded-xl overflow-hidden shadow-sm min-h-[400px] relative">
<div ref={containerRef} className="w-full h-full flex flex-col bg-card border rounded-xl overflow-hidden shadow-sm min-h-[400px] relative" style={{ maxHeight: effectiveMaxHeight, transition: 'max-height 0.3s ease' }}>
{loading ? (
<div className="flex-1 flex flex-col items-center justify-center bg-gray-50/50 dark:bg-gray-900/50">
<Loader2 className="w-8 h-8 animate-spin text-primary mb-2" />
<p className="text-sm text-muted-foreground"><T>Loading map data...</T></p>
</div>
) : (
<div className="flex-1 w-full min-h-0 relative flex">
<div className="flex-1 min-w-0 min-h-0">
<CompetitorsMapView
<div className="flex-1 w-full min-h-0 flex overflow-hidden">
<div className="flex-1 min-w-0 h-full">
<PlacesMapView
preset={preset}
customFeatures={{
enableSimulator: showSettings === false ? false : enableSimulator,
@ -183,7 +147,7 @@ const CompetitorsMapWidget: React.FC<CompetitorsMapWidgetProps> = ({
initialCenter={initialCenter}
initialGadmRegions={finalGadmRegions}
initialSimulatorSettings={initialSimulatorSettings}
competitors={competitors}
places={competitors}
onMapCenterUpdate={handleMapCenterUpdate}
enrich={dummyEnrich}
isEnriching={false}
@ -191,20 +155,21 @@ const CompetitorsMapWidget: React.FC<CompetitorsMapWidgetProps> = ({
isPosterMode={posterMode}
selectedPlaceId={selectedPlaceId}
onSelectPlace={enableLocationDetails ? handleSelectPlace : undefined}
onMapReady={handleMapReady}
/>
</div>
{/* Location Detail Sidebar — mirrors GridSearchResults pattern */}
{enableLocationDetails && showDetails && activeCompetitor && (
<div className="flex h-full min-h-0 shrink-0">
<div className="flex h-full shrink-0 overflow-hidden">
<div
className="w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize flex items-center justify-center z-30 transition-colors shrink-0"
onMouseDown={startResizing}
>
<GripVertical className="w-3 h-3 text-gray-400" />
</div>
<div
className="h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0 border-l border-gray-200 dark:border-gray-700"
<div
className="h-full bg-white dark:bg-gray-800 z-20 overflow-y-auto relative shrink-0 border-l border-gray-200 dark:border-gray-700"
style={{ width: sidebarWidth }}
>
<LocationDetailView
@ -218,6 +183,8 @@ const CompetitorsMapWidget: React.FC<CompetitorsMapWidgetProps> = ({
)}
</div>
);
};
});
CompetitorsMapWidget.displayName = 'CompetitorsMapWidget';
export default CompetitorsMapWidget;

View File

@ -23,8 +23,6 @@ export default function SearchesTab({ onUpdate, initialSelection, currentSelecti
// Local override for immediate feedback while fetching metadata
const [localSelectedId, setLocalSelectedId] = useState<string | null>(null);
const activeJobId = localSelectedId || currentSelection?.jobId || initialSelection?.jobId;
// console.log('SearchesTab render', { activeJobId, currentSelectionId: currentSelection?.jobId, initialSelectionId: initialSelection?.jobId });
useEffect(() => {
const load = async () => {
@ -59,7 +57,7 @@ export default function SearchesTab({ onUpdate, initialSelection, currentSelecti
// Minimal fetch: just get the job search summary/request for label and center
const fullJob = await fetchPlacesGridSearchById(job.id);
const data = fullJob?.data || fullJob;
// Try to find a center point if available
const lat = data?.request?.enumerate?.lat || data?.request?.search?.lat || data?.query?.lat;
const lng = data?.request?.enumerate?.lng || data?.request?.search?.lng || data?.query?.lng;
@ -71,7 +69,7 @@ export default function SearchesTab({ onUpdate, initialSelection, currentSelecti
lat: lat ? parseFloat(lat) : undefined,
lng: lng ? parseFloat(lng) : undefined
};
// console.log('SearchesTab updating parent', selection);
onUpdate(selection);
} catch (err) {

View File

@ -0,0 +1,210 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { PlaceFull } from '@polymech/shared';
import { fetchPlacesGridSearchById } from '@/modules/places/client-gridsearch';
import maplibregl from 'maplibre-gl';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PlacesMapWidgetHandle {
/** Zoom the map to a specific level (0-22). Optionally animate. */
zoomTo: (zoom: number, options?: { duration?: number }) => void;
/** Center (and optionally zoom) the map on a lat/lng coordinate. */
centerTo: (lat: number, lng: number, options?: { zoom?: number; duration?: number }) => void;
/** Replace the displayed location markers with a new set. */
setLocations: (locations: PlaceFull[]) => void;
/** Replace the GADM regions displayed on the map. */
setRegions: (regions: Array<{ gid: string; name: string; level: number }>) => void;
/** Update the grid-search simulator settings. */
setSimSettings: (settings: Record<string, any>) => void;
/** Direct access to the underlying MapLibre instance (may be null). */
getMap: () => maplibregl.Map | null;
}
export interface GadmRegion {
gid: string;
name: string;
level: number;
}
interface UsePlacesMapDataOptions {
jobId?: string;
targetLocation?: any;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function usePlacesMapData({ jobId, targetLocation }: UsePlacesMapDataOptions) {
const [competitors, setCompetitors] = useState<PlaceFull[]>([]);
const [simulatorSettings, setSimulatorSettings] = useState<any>(null);
const [fetchedRegions, setFetchedRegions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const mapRef = useRef<maplibregl.Map | null>(null);
const hasFittedRef = useRef(false);
// Imperative region override — when set, takes priority over fetched/picker regions
const [imperativeRegions, setImperativeRegions] = useState<GadmRegion[] | null>(null);
// -----------------------------------------------------------------------
// Derived: effective job ID, initial center, GADM regions, sim settings
// -----------------------------------------------------------------------
const effectiveJobId = targetLocation?.jobId || jobId;
const pickerLat = targetLocation?.lat ?? targetLocation?.center?.lat;
const pickerLng = targetLocation?.lng ?? targetLocation?.center?.lng;
const initialCenter = useMemo(
() => (pickerLat && pickerLng ? { lat: pickerLat, lng: pickerLng } : undefined),
[pickerLat, pickerLng],
);
// GADM regions: imperative override > picker > fetched
const baseRegions = imperativeRegions ?? (targetLocation?.gadmRegions?.length ? targetLocation.gadmRegions : fetchedRegions);
const normalizedGadmRegions: GadmRegion[] = baseRegions.map((r: any) => ({
gid: r.gid,
name: r.gadmName || r.name,
level: r.level !== undefined ? (typeof r.level === 'string' ? parseInt(r.level.replace(/\D/g, '')) : r.level) : 0,
}));
const finalGadmRegions = normalizedGadmRegions.length > 0
? normalizedGadmRegions
: targetLocation?.gid
? [{ gid: targetLocation.gid, name: targetLocation.label || targetLocation.name, level: targetLocation.level }]
: undefined;
const initialSimulatorSettings = targetLocation?.simulatorSettings || simulatorSettings;
// -----------------------------------------------------------------------
// Fetch data when a job ID is available
// -----------------------------------------------------------------------
useEffect(() => {
if (!effectiveJobId) {
setCompetitors([]);
setSimulatorSettings(null);
return;
}
// Short-circuit when the picker already carries the full payload
if (targetLocation?.jobId === effectiveJobId && targetLocation?.competitors && targetLocation?.simulatorSettings) {
setCompetitors(targetLocation.competitors);
setSimulatorSettings(targetLocation.simulatorSettings);
return;
}
let isMounted = true;
(async () => {
setLoading(true);
try {
const job = await fetchPlacesGridSearchById(effectiveJobId);
const data = job?.data || job;
if (isMounted && data) {
const enrichResults = data.result?.enrichResults;
if (Array.isArray(enrichResults)) {
setCompetitors(enrichResults);
} else if (Array.isArray(data.places)) {
setCompetitors(data.places);
}
if (data.request?.guided?.settings) {
setSimulatorSettings(data.request.guided.settings);
}
if (Array.isArray(data.areas)) {
setFetchedRegions(data.areas);
} else if (data.request?.guided?.areas) {
setFetchedRegions(data.request.guided.areas);
}
}
} catch (err) {
console.error('Failed to load search job points:', err);
if (isMounted) setCompetitors([]);
} finally {
if (isMounted) setLoading(false);
}
})();
return () => { isMounted = false; };
}, [effectiveJobId]);
// -----------------------------------------------------------------------
// Auto-fit helpers
// -----------------------------------------------------------------------
const competitorsRef = useRef(competitors);
competitorsRef.current = competitors;
const fitToPlaces = useCallback((m: maplibregl.Map, locs: PlaceFull[]) => {
if (hasFittedRef.current || locs.length === 0) return;
const bounds = new maplibregl.LngLatBounds();
let hasPoints = false;
for (const c of locs) {
const lat = (c as any).gps_coordinates?.latitude ?? (c as any).raw_data?.geo?.latitude ?? (c as any).lat;
const lon = (c as any).gps_coordinates?.longitude ?? (c as any).raw_data?.geo?.longitude ?? (c as any).lon;
if (lat != null && lon != null) { bounds.extend([Number(lon), Number(lat)]); hasPoints = true; }
}
if (hasPoints && !bounds.isEmpty()) {
m.fitBounds(bounds, { padding: 50, maxZoom: 9, duration: 1000 });
hasFittedRef.current = true;
}
}, []);
// Case 2: Data arrives after map is ready
useEffect(() => {
if (mapRef.current && competitors.length > 0 && !hasFittedRef.current) {
fitToPlaces(mapRef.current, competitors);
}
}, [competitors, fitToPlaces]);
// -----------------------------------------------------------------------
// Imperative API handle (stable — never changes identity)
// -----------------------------------------------------------------------
const handle: PlacesMapWidgetHandle = useMemo(() => ({
zoomTo(zoom, options) {
mapRef.current?.zoomTo(zoom, { duration: options?.duration ?? 500 });
},
centerTo(lat, lng, options) {
if (!mapRef.current) return;
mapRef.current.flyTo({
center: [lng, lat],
zoom: options?.zoom ?? mapRef.current.getZoom(),
duration: options?.duration ?? 1000,
});
},
setLocations(locations) {
hasFittedRef.current = false; // allow re-fit
setCompetitors(locations);
},
setRegions(regions) {
setImperativeRegions(regions);
},
setSimSettings(settings) {
setSimulatorSettings(settings);
},
getMap() {
return mapRef.current;
},
}), []);
// -----------------------------------------------------------------------
// onMapReady — wires map ref + initial fit
// -----------------------------------------------------------------------
const handleMapReady = useCallback((m: maplibregl.Map) => {
mapRef.current = m;
fitToPlaces(m, competitorsRef.current);
}, [fitToPlaces]);
return {
// Data
competitors,
loading,
initialCenter,
finalGadmRegions,
initialSimulatorSettings,
// Imperative
handle,
handleMapReady,
mapRef,
hasFittedRef,
};
}

View File

@ -10,6 +10,7 @@ import {
MessageSquare,
Map as MapIcon,
} from 'lucide-react';
import type {
HtmlWidgetProps,
PhotoGridProps,
@ -24,7 +25,6 @@ import type {
} from '@polymech/shared';
import PageCardWidget from '@/modules/pages/PageCardWidget';
import PhotoGrid from '@/components/PhotoGrid';
import PhotoGridWidget from '@/components/widgets/PhotoGridWidget';
import PhotoCardWidget from '@/components/widgets/PhotoCardWidget';
@ -36,10 +36,10 @@ import { HtmlWidget } from '@/components/widgets/HtmlWidget';
import HomeWidget from '@/components/widgets/HomeWidget';
import VideoBannerWidget from '@/components/widgets/VideoBannerWidget';
import CategoryFeedWidget from '@/components/widgets/CategoryFeedWidget';
import SupportChatWidget from '@/components/widgets/SupportChatWidget';
import MenuWidget from '@/components/widgets/MenuWidget';
import CompetitorsMapWidget from '@/components/widgets/CompetitorsMapWidget';
const SupportChatWidget = lazy(() => import('@/components/widgets/SupportChatWidget'));
const CompetitorsMapWidget = lazy(() => import('@/components/widgets/CompetitorsMapWidget'));
const FileBrowserWidget = lazy(() => import('@/modules/storage/FileBrowserWidget').then(m => ({ default: m.FileBrowserWidget })));
export function registerAllWidgets() {
@ -1306,6 +1306,7 @@ export function registerAllWidgets() {
showLocations: true,
posterMode: false,
enableLocationDetails: true,
maxHeight: '800px',
preset: 'Minimal',
variables: {}
},
@ -1378,6 +1379,12 @@ export function registerAllWidgets() {
label: 'Allow Location Details',
description: 'Click on a location to open the detail panel with address, ratings, and contact info.',
default: true
},
maxHeight: {
type: 'string',
label: 'Max Height',
description: 'Maximum height of the map widget (e.g. 800px, 100vh, none).',
default: '800px'
}
},
minSize: { width: 400, height: 400 },

View File

@ -4,13 +4,13 @@ import { ArrowLeft, MapPin, Globe, Phone, Clock, Calendar, Image as ImageIcon, I
import Lightbox from "yet-another-react-lightbox";
import "yet-another-react-lightbox/styles.css";
import { API_URL, THUMBNAIL_WIDTH } from '../../constants';
import type { CompetitorFull } from '@polymech/shared';
import type { PlaceFull } from '@polymech/shared';
import { fetchCompetitorById, fetchPlacePhotos } from './client-gridsearch';
import MarkdownRenderer from '../../components/MarkdownRenderer';
import { T, translate } from '../../i18n';
// Extracted Presentation Component
export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }) => {
export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: PlaceFull; onClose?: () => void; livePhotos?: any }) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview');
@ -22,7 +22,7 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos
// Reset local fetched state when competitor changes
setFetchedPhotos(null);
setIsFetchingPhotos(false);
// Fetch photos on-the-fly (async, non-blocking) if we don't already have them
if (!livePhotos && !competitor.raw_data?.google_media?.photos?.length) {
setIsFetchingPhotos(true);
@ -449,7 +449,7 @@ export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos
const LocationDetail: React.FC = () => {
const { place_id } = useParams<{ place_id: string }>();
const [competitor, setCompetitor] = useState<CompetitorFull | null>(null);
const [competitor, setCompetitor] = useState<PlaceFull | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [livePhotos, setLivePhotos] = useState<any>(null);

View File

@ -1,24 +1,24 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { type GridPreset, getPresetVisibilityModel } from './useGridColumns';
import {
DataGrid,
useGridApiRef,
import {
DataGrid,
useGridApiRef,
GridToolbarContainer,
GridToolbarColumnsButton,
GridToolbarFilterButton,
GridToolbarExport,
GridToolbarQuickFilter,
type GridColDef,
type GridFilterModel,
type GridPaginationModel,
type GridSortModel,
type GridColumnVisibilityModel
type GridColDef,
type GridFilterModel,
type GridPaginationModel,
type GridSortModel,
type GridColumnVisibilityModel
} from '@mui/x-data-grid';
import { type CompetitorFull } from '@polymech/shared';
import { type PlaceFull } from '@polymech/shared';
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { useMuiTheme } from '@/hooks/useMuiTheme';
import { type CompetitorSettings } from './useCompetitorSettings';
import { type PlacesSettings } from './usePlacesSettings';
// Extracted utils and components
import {
@ -31,12 +31,11 @@ import {
paramsToColumnOrder
} from './gridUtils';
import { useGridColumns } from './useGridColumns';
import { GripVertical } from 'lucide-react';
interface CompetitorsGridViewProps {
competitors: CompetitorFull[];
interface PlacesGridViewProps {
competitors: PlaceFull[];
loading: boolean;
settings: CompetitorSettings;
settings: PlacesSettings;
updateExcludedTypes: (types: string[]) => Promise<void>;
selectedPlaceId?: string | null;
onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void;
@ -54,7 +53,7 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
<GridToolbarExport />
<GridToolbarQuickFilter className="w-48 ml-2" />
</div>
<div className="flex items-center h-8 ml-auto pr-2 transition-opacity duration-200">
{selectedCount > 0 && (
<span className="text-xs font-semibold px-2.5 py-0.5 rounded-full bg-indigo-100 text-indigo-700 dark:bg-indigo-900/60 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 select-none shadow-sm">
@ -66,12 +65,12 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
);
};
export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
competitors,
loading,
settings,
updateExcludedTypes,
selectedPlaceId,
export const CompetitorsGridView: React.FC<PlacesGridViewProps> = ({
competitors,
loading,
settings,
updateExcludedTypes,
selectedPlaceId,
onSelectPlace,
isOwner = false,
isPublic = false,
@ -86,7 +85,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
if (fromUrl.items.length === 0 && !searchParams.has('nofilter')) {
// Only apply default "valid leads" filter if we are NOT in a public stripped view
const shouldHideEmptyEmails = !isPublic || isOwner;
if (shouldHideEmptyEmails) {
return {
items: [{ field: 'email', operator: 'isNotEmpty' }]
@ -138,7 +137,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
// Selection state
const [selectedRows, setSelectedRows] = useState<string[]>([]);
const apiRef = useGridApiRef();
const containerRef = useRef<HTMLDivElement>(null);
const [highlightedRowId, setHighlightedRowId] = useState<string | null>(null);
@ -158,7 +157,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
settings,
updateExcludedTypes
});
// Sync Visibility Model when preset changes
useEffect(() => {
const presetModel = getPresetVisibilityModel(preset);
@ -328,10 +327,10 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
const allSortedIds = apiRef.current.getSortedRowIds?.() || [];
// Retrieve MUI's internal lookup of which rows are hidden by filters
const filteredRowsLookup = apiRef.current.state?.filter?.filteredRowsLookup || {};
// Keep only rows that are NOT explicitly filtered out
const rowIds = allSortedIds.filter(id => filteredRowsLookup[id] !== false);
if (rowIds.length === 0) return;
const maxIdx = rowIds.length - 1;
@ -381,14 +380,14 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
const nextId = String(rowIds[nextIdx]);
setHighlightedRowId(nextId);
onSelectPlace?.(nextId, 'select');
if (e.shiftKey) {
const anchorIdx = anchorRowId ? rowIds.indexOf(anchorRowId) : currentIdx;
const effectiveAnchor = anchorIdx >= 0 ? anchorIdx : currentIdx;
const start = Math.min(effectiveAnchor, nextIdx);
const end = Math.max(effectiveAnchor, nextIdx);
const newRange = rowIds.slice(start, end + 1).map(id => String(id));
if (e.ctrlKey || e.metaKey) {
setSelectedRows(prev => Array.from(new Set([...prev, ...newRange])));
} else {
@ -418,7 +417,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
}, [selectedPlaceId, highlightedRowId, competitors]);
return (
<div
<div
ref={containerRef}
className="flex w-full h-full flex-row outline-none focus:ring-2 focus:ring-primary/20 focus:ring-inset rounded-lg"
tabIndex={0}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { Map as MapIcon, Sun, Moon, GripVertical, Maximize, Locate, Loader2, LayoutGrid, X } from 'lucide-react';
import { GripVertical, Loader2, X } from 'lucide-react';
import { RulerButton } from './components/RulerButton';
import { type CompetitorFull } from '@polymech/shared';
import { type PlaceFull } from '@polymech/shared';
import { fetchRegionBoundary } from './client-gridsearch';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { InfoPanel } from './InfoPanel';
import { GadmPicker } from './gadm-picker';
import { GridSearchSimulator } from './gridsearch/simulator/GridSearchSimulator';
import { Info, Sparkles, Crosshair } from 'lucide-react';
import { Sparkles } from 'lucide-react';
import { useMapControls } from './hooks/useMapControls';
import { MapFooter } from './components/MapFooter';
import { MAP_STYLES, type MapStyleKey } from './components/map-styles';
@ -92,10 +92,10 @@ export const MAP_PRESETS: Record<MapPreset, MapFeatures> = {
}
};
interface CompetitorsMapViewProps {
interface PlacesMapViewProps {
preset?: MapPreset;
customFeatures?: Partial<MapFeatures>;
competitors: CompetitorFull[];
places: PlaceFull[];
onMapCenterUpdate: (loc: string, zoom?: number) => void;
initialCenter?: { lat: number, lng: number };
initialZoom?: number;
@ -120,9 +120,11 @@ interface CompetitorsMapViewProps {
setPosterTheme?: (theme: string) => void;
selectedPlaceId?: string | null;
onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void;
/** Optional callback fired once after the internal map instance is created. */
onMapReady?: (map: maplibregl.Map) => void;
}
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace }) => {
export const PlacesMapView: React.FC<PlacesMapViewProps> = ({ places, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace, onMapReady }) => {
const features: MapFeatures = useMemo(() => {
if (isPosterMode) {
return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false };
@ -154,16 +156,16 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
}, [theme]);
// Selection and Sidebar State
const [selectedLocation, setSelectedLocation] = useState<CompetitorFull | null>(null);
const [selectedLocation, setSelectedLocation] = useState<PlaceFull | null>(null);
const handleSelectLocation = useCallback((loc: CompetitorFull | null, behavior: 'select' | 'open' | 'toggle' = 'open') => {
const handleSelectLocation = useCallback((loc: PlaceFull | null, behavior: 'select' | 'open' | 'toggle' = 'open') => {
setSelectedLocation(loc);
onSelectPlace?.(loc?.place_id || null, behavior);
}, [onSelectPlace]);
// Add logic to label locations A, B, C...
const validLocations = useMemo(() => {
return competitors
return places
.map((c, i) => {
const lat = c.gps_coordinates?.latitude ?? c.raw_data?.geo?.latitude ?? (c as any).lat;
const lon = c.gps_coordinates?.longitude ?? c.raw_data?.geo?.longitude ?? (c as any).lon;
@ -179,7 +181,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
return null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
}, [competitors]);
}, [places]);
const locationIds = useMemo(() => {
return validLocations.map(l => l.place_id).sort().join(',');
@ -198,6 +200,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
}, [selectedPlaceId, validLocations]);
const [gadmPickerActive, setGadmPickerActive] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(features.showSidebar ?? false);
const lastInitialGidsRef = useRef<string | null>(null);
// Grid Search Simulator State
const [simulatorActive, setSimulatorActive] = useState(false);
@ -241,6 +244,11 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
useEffect(() => {
if (!features.enableAutoRegions || !initialGadmRegions || initialGadmRegions.length === 0) return;
// Prevent redundant loads if gids haven't changed (prevents flickering Expand button)
const gidsKey = initialGadmRegions.map(r => r.gid).sort().join(',');
if (lastInitialGidsRef.current === gidsKey) return;
lastInitialGidsRef.current = gidsKey;
(async () => {
const regions: any[] = [];
const polygons: any[] = [];
@ -260,7 +268,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
}
setPickerRegions(regions);
setPickerPolygons(polygons);
// Allow bounds to be fitted again for new regions
hasFittedBoundsRef.current = false;
})();
@ -353,6 +361,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
useEffect(() => {
if (map.current || !mapContainer.current) return;
console.log('initialCenter', initialCenter);
map.current = new maplibregl.Map({
container: mapContainer.current,
style: MAP_STYLES[mapStyleKey],
@ -387,6 +397,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
});
const cleanupListeners = setupMapListeners(map.current, onMapMoveRef.current);
onMapReady?.(map.current);
return () => {
cleanupListeners();
@ -397,18 +408,6 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
}
};
}, []);
// Needed for widget mode: React to initialCenter updates (e.g. from picker)
useEffect(() => {
if (map.current && initialCenter) {
map.current.flyTo({
center: [initialCenter.lng, initialCenter.lat],
zoom: initialZoom ?? (map.current.getZoom() < 8 ? 13 : map.current.getZoom()),
duration: 2000
});
}
}, [initialCenter, initialZoom]);
const lastFittedLocationIdsRef = useRef<string | null>(null);
// Handle Data Updates - No longer creates manual markers. LocationLayers handles this via WebGL.
@ -419,16 +418,17 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
if (validLocations.length > 0 && lastFittedLocationIdsRef.current !== locationIds) {
const isInitialLoad = lastFittedLocationIdsRef.current === null;
// Only fit bounds on the first-time data detection.
// We skip camera movement for subsequent live updates (discovered locations).
if (!isPosterMode && !initialCenter && isInitialLoad) {
// Fit bounds on first-time data load only (skip poster mode and subsequent live updates).
// initialCenter only affects the map's starting position (in the init effect),
// it should not prevent fitting to actual data once it arrives.
if (!isPosterMode && isInitialLoad) {
const bounds = new maplibregl.LngLatBounds();
validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat]));
map.current.fitBounds(bounds, { padding: 50, maxZoom: 15 });
}
lastFittedLocationIdsRef.current = locationIds;
}
}, [locationIds, validLocations, initialCenter, isPosterMode]);
}, [locationIds, validLocations, isPosterMode]);
// Sync Theme/Style
@ -588,6 +588,11 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<style>{`
.map-popup-override { z-index: 20 !important; }
.map-popup-override .maplibregl-popup-content { padding: 0; border-radius: 0.5rem; }
/* Force crosshair when GADM picker is active to prevent layer hover overrides */
.gadm-picker-active .maplibregl-canvas {
cursor: crosshair !important;
}
`}</style>
{/* Split View Container */}
@ -630,6 +635,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
onSelectionChange={(r, p) => {
setPickerRegions(r);
setPickerPolygons(p);
onRegionsChange?.(r);
}}
initialRegions={initialGadmRegions}
/>
@ -687,7 +693,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
)}
{/* Map Viewport */}
<div className="relative flex-1 min-h-0 w-full overflow-hidden">
<div className={`relative flex-1 min-h-0 w-full overflow-hidden ${gadmPickerActive ? 'gadm-picker-active' : ''}`}>
<div
ref={mapContainer}
className="w-full h-full relative"
@ -768,68 +774,68 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Needed for widget mode: mt-auto wrapper to force footer to the bottom of the flex stack */}
<div className="mt-auto">
<MapFooter
map={map.current}
currentCenterLabel={currentCenterLabel}
mapInternals={mapInternals}
isLocating={isLocating}
onLocate={() => handleLocate(map.current)}
onZoomToFit={() => {
if (!map.current) return;
const bounds = new maplibregl.LngLatBounds();
let hasPoints = false;
map={map.current}
currentCenterLabel={currentCenterLabel}
mapInternals={mapInternals}
isLocating={isLocating}
onLocate={() => handleLocate(map.current)}
onZoomToFit={() => {
if (!map.current) return;
const bounds = new maplibregl.LngLatBounds();
let hasPoints = false;
if (validLocations.length > 0) {
validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat]));
hasPoints = true;
}
if (validLocations.length > 0) {
validLocations.forEach(loc => bounds.extend([loc.lon, loc.lat]));
hasPoints = true;
}
if (pickerPolygons.length > 0) {
pickerPolygons.forEach(fc => {
fc?.features?.forEach((f: any) => {
const coords = f.geometry?.coordinates;
if (!coords) return;
if (f.geometry.type === 'MultiPolygon') {
coords.forEach((poly: any) => poly[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; }));
} else if (f.geometry.type === 'Polygon') {
coords[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; });
}
if (pickerPolygons.length > 0) {
pickerPolygons.forEach(fc => {
fc?.features?.forEach((f: any) => {
const coords = f.geometry?.coordinates;
if (!coords) return;
if (f.geometry.type === 'MultiPolygon') {
coords.forEach((poly: any) => poly[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; }));
} else if (f.geometry.type === 'Polygon') {
coords[0]?.forEach((c: any) => { bounds.extend(c); hasPoints = true; });
}
});
});
});
}
}
if (hasPoints && !bounds.isEmpty()) {
map.current.fitBounds(bounds, { padding: 100, maxZoom: 15 });
}
}}
activeStyleKey={mapStyleKey}
onStyleChange={setMapStyleKey}
>
{features.enableLayerToggles && (
<MapLayerToggles
showDensity={showDensity}
onToggleDensity={setShowDensity}
showCenters={showCenters}
onToggleCenters={setShowCenters}
/>
)}
{features.enableRuler && <RulerButton map={map.current} />}
{features.enableEnrichment && (
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title={translate("Enrich Visible Locations")}
>
<Sparkles className="w-3.5 h-3.5" />
</button>
)}
if (hasPoints && !bounds.isEmpty()) {
map.current.fitBounds(bounds, { padding: 100, maxZoom: 15 });
}
}}
activeStyleKey={mapStyleKey}
onStyleChange={setMapStyleKey}
>
{features.enableLayerToggles && (
<MapLayerToggles
showDensity={showDensity}
onToggleDensity={setShowDensity}
showCenters={showCenters}
onToggleCenters={setShowCenters}
/>
)}
{features.enableRuler && <RulerButton map={map.current} />}
{features.enableEnrichment && (
<button
onClick={() => enrich(locationIds.split(','), ['meta'])}
disabled={isEnriching || validLocations.length === 0}
className={`p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 ${isEnriching ? 'text-indigo-600 animate-pulse' : 'text-gray-500'}`}
title={translate("Enrich Visible Locations")}
>
<Sparkles className="w-3.5 h-3.5" />
</button>
)}
{features.enableEnrichment && enrichmentProgress && (
<div className="absolute bottom-10 right-4 bg-white dark:bg-gray-800 p-2 rounded shadow text-xs border border-gray-200 dark:border-gray-700 flex items-center gap-2 z-50">
<Loader2 className="w-3 h-3 animate-spin text-indigo-500" />
<span>{enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total})</span>
</div>
)}
{features.enableEnrichment && enrichmentProgress && (
<div className="absolute bottom-10 right-4 bg-white dark:bg-gray-800 p-2 rounded shadow text-xs border border-gray-200 dark:border-gray-700 flex items-center gap-2 z-50">
<Loader2 className="w-3 h-3 animate-spin text-indigo-500" />
<span>{enrichmentProgress.message} ({enrichmentProgress.current}/{enrichmentProgress.total})</span>
</div>
)}
</MapFooter>
</div>
</div>

View File

@ -1,21 +1,21 @@
import React, { useMemo } from 'react';
import { type CompetitorFull } from '@polymech/shared';
import { type PlaceFull } from '@polymech/shared';
import { PieChart, AlertCircle, CheckCircle2, XCircle } from 'lucide-react';
import type { CompetitorSettings } from './useCompetitorSettings';
import type { CompetitorSettings } from './usePlacesSettings';
interface CompetitorsMetaViewProps {
competitors: CompetitorFull[];
interface PlacesMetaViewProps {
places: PlaceFull[];
settings: CompetitorSettings;
updateExcludedTypes: (types: string[]) => void;
}
export const CompetitorsMetaView: React.FC<CompetitorsMetaViewProps> = ({ competitors, settings, updateExcludedTypes }) => {
export const PlacesMetaView: React.FC<PlacesMetaViewProps> = ({ places, settings, updateExcludedTypes }) => {
// Aggregate types
const typeStats = useMemo(() => {
const stats: Record<string, number> = {};
competitors.forEach(comp => {
places.forEach(comp => {
if (comp.types) {
comp.types.forEach(t => {
stats[t] = (stats[t] || 0) + 1;
@ -33,7 +33,7 @@ export const CompetitorsMetaView: React.FC<CompetitorsMetaViewProps> = ({ compet
// If we want to UN-exclude, we need to list the excluded types somewhere.
return Object.entries(stats).sort((a, b) => b[1] - a[1]);
}, [competitors]);
}, [places]);
const handleToggleExclusion = (type: string) => {
const currentExcluded = settings.excluded_types || [];

View File

@ -12,16 +12,16 @@ import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Checkbox } from '@/components/ui/checkbox';
import { Search, Loader2 } from 'lucide-react';
import { useCompetitorSettings, type CompetitorSettings } from './useCompetitorSettings';
import { type PlacesSettings } from './usePlacesSettings';
interface CompetitorSettingsDialogProps {
interface PlacesSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
settings: CompetitorSettings;
settings: PlacesSettings;
updateExcludedTypes: (types: string[]) => Promise<void>;
}
export const CompetitorSettingsDialog: React.FC<CompetitorSettingsDialogProps> = ({
export const PlacesSettingsDialog: React.FC<PlacesSettingsDialogProps> = ({
open,
onOpenChange,
settings,

View File

@ -1,50 +1,50 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { MapPin, Globe, Phone, Clock } from 'lucide-react';
import { type CompetitorFull } from '@polymech/shared';
import { THUMBNAIL_WIDTH } from '../../constants';
import { type PlaceFull } from '@polymech/shared';
import { THUMBNAIL_WIDTH } from '@/constants';
interface CompetitorsThumbViewProps {
competitors: CompetitorFull[];
interface PlacesThumbViewProps {
places: PlaceFull[];
filters: string[];
toggleFilter: (filter: string) => void;
}
export const CompetitorsThumbView: React.FC<CompetitorsThumbViewProps> = ({
competitors,
export const PlacesThumbView: React.FC<PlacesThumbViewProps> = ({
places,
filters,
toggleFilter
}) => {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{competitors.map((competitor) => {
let imageUrl = competitor.raw_data?.google_media?.photos?.[0]?.image;
{places.map((place) => {
let imageUrl = place.raw_data?.google_media?.photos?.[0]?.image;
if (imageUrl) {
imageUrl = imageUrl.replace(/=w\d+[^&]*/, `=w${THUMBNAIL_WIDTH}`);
}
imageUrl = imageUrl || competitor.thumbnail || undefined;
imageUrl = imageUrl || place.thumbnail || undefined;
return (
<div key={competitor.place_id} className="border overflow-hidden">
<div key={place.place_id} className="border overflow-hidden">
{imageUrl && (
<div className="h-48 w-full overflow-hidden ">
<img
src={imageUrl}
alt={competitor.title}
alt={place.title}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
/>
</div>
)}
<div className="px-4 py-5 sm:p-6">
<Link to={`/products/places/detail/${competitor.place_id}`} className="block">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-2 hover:text-indigo-600">{competitor.title}</h3>
<Link to={`/products/places/detail/${place.place_id}`} className="block">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-2 hover:text-indigo-600">{place.title}</h3>
</Link>
<div className="mb-3 flex flex-wrap gap-1">
{(() => {
const businessTypes = competitor.types || [];
const mediaCategories = competitor.raw_data?.google_media?.categories || [];
const city = competitor.city || competitor.raw_data?.geo?.city;
const businessTypes = place.types || [];
const mediaCategories = place.raw_data?.google_media?.categories || [];
const city = place.city || place.raw_data?.geo?.city;
// Display up to 3 items total, prioritizing city, then business types
const displayLimit = 3;
@ -127,23 +127,23 @@ export const CompetitorsThumbView: React.FC<CompetitorsThumbViewProps> = ({
<div className="space-y-2 text-sm text-gray-500">
<div className="flex items-start">
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0 text-gray-400" />
<span>{competitor.address}</span>
<span>{place.address}</span>
</div>
{competitor.phone && (
{place.phone && (
<div className="flex items-center">
<Phone className="h-4 w-4 mr-2 flex-shrink-0 text-gray-400" />
<span>{competitor.phone}</span>
<span>{place.phone}</span>
</div>
)}
{competitor.website && (
{place.website && (
<div className="flex items-center">
<Globe className="h-4 w-4 mr-2 flex-shrink-0 text-gray-400" />
<a href={competitor.website} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-500 truncate">
<a href={place.website} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-500 truncate">
Visit Website
</a>
</div>
)}
{competitor.operating_hours && (
{place.operating_hours && (
<div className="flex items-start">
<Clock className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0 text-gray-400" />
<span className="text-xs">Operating hours available</span>

View File

@ -178,18 +178,18 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
</div>
{/* Bottom Content Area */}
<div className="w-full h-72 absolute bottom-0 left-0 flex flex-col justify-end items-center pb-12" style={{ background: gradientBottom }}>
<div className="w-full h-72 absolute bottom-0 left-0 flex flex-col justify-end items-center pb-3" style={{ background: gradientBottom }}>
<div
className="text-center transition-colors duration-500"
style={{ color: theme.text }}
>
<div className="font-bold tracking-[0.2em] text-5xl mb-6 font-mono leading-none drop-shadow-sm">
<div className="font-bold tracking-[0.2em] text-3xl mb-4 font-mono leading-none drop-shadow-sm">
{displayCity}
</div>
<div className="w-20 h-[2px] mx-auto opacity-70 mb-5 drop-shadow-sm" style={{ backgroundColor: theme.text }} />
<div className="w-16 h-[2px] mx-auto opacity-70 mb-4 drop-shadow-sm" style={{ backgroundColor: theme.text }} />
<div className="text-2xl tracking-[0.3em] font-light mb-3 drop-shadow-sm">
<div className="text-lg tracking-[0.3em] font-light mb-2 drop-shadow-sm">
{country.toUpperCase()}
</div>
@ -197,14 +197,6 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
{center ? formatCoords(center) : 'Loading...'}
</div>
</div>
{/* Attribution */}
<div
className="absolute bottom-4 right-4 text-xs opacity-50 font-mono"
style={{ color: theme.text }}
>
© OpenStreetMap contributors
</div>
</div>
</div>
);

View File

@ -19,13 +19,6 @@ export interface GadmRegion {
function createMarkerEl(): HTMLElement {
const el = document.createElement('div');
el.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="#0ea5e9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="filter:drop-shadow(0 3px 4px rgba(0,0,0,0.25));"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" fill="white" fill-opacity="0.92"/><circle cx="12" cy="10" r="3" fill="#0ea5e9"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="2" y1="10" x2="5" y2="10"/><line x1="19" y1="10" x2="22" y2="10"/></svg>`;
el.style.cursor = 'move';
return el;
}
const MAX_DISPLAY_LEVEL: Record<number, number> = {
0: 1,
1: 3,
@ -61,8 +54,8 @@ const LEVEL_OPTIONS = [
];
export function GadmPicker({ map, active, onClose, onSelectionChange, className = "", initialRegions }: GadmPickerProps) {
const [levelOption, setLevelOption] = useState<number>(0);
const [resolutionOption, setResolutionOption] = useState<number>(1);
const [levelOption, setLevelOption] = useState<number>(3);
const [resolutionOption, setResolutionOption] = useState<number>(3);
const enrich = true;
// UI state
@ -82,7 +75,6 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
const [geojsons, setGeojsons] = useState<Record<string, any>>({});
// Extracted Inspector State
const markerRef = useRef<maplibregl.Marker | null>(null);
const [inspectedHierarchy, setInspectedHierarchy] = useState<any[] | null>(null);
const [inspectedPoint, setInspectedPoint] = useState<{ lat: number, lng: number } | null>(null);
const [inspectedGeojson, setInspectedGeojson] = useState<any>(null);
@ -188,24 +180,54 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
};
}, [map, updateMapFeatures]);
// Handle Inspector Marker
// Map Interactions
useEffect(() => {
if (!map || !active) return;
const handleMapClick = (e: maplibregl.MapMouseEvent) => {
// Left click handles normal inspection (or multi-select if Ctrl is held)
if (e.originalEvent.button === 0) {
performInspection(e.lngLat.lat, e.lngLat.lng, e.originalEvent.ctrlKey || e.originalEvent.metaKey);
}
};
const handleMouseDown = (e: maplibregl.MapMouseEvent) => {
// Middle click (button 1) acts as a shortcut for "Add to selection"
if (e.originalEvent.button === 1) {
performInspection(e.lngLat.lat, e.lngLat.lng, true);
// Prevent MapLibre from starting a drag-rotate with middle mouse
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
}
};
map.on('click', handleMapClick);
map.on('mousedown', handleMouseDown);
const canvas = map.getCanvas();
const oldCursor = canvas.style.cursor;
canvas.style.cursor = 'crosshair';
const handleMouseEnter = () => { canvas.style.cursor = 'crosshair'; };
map.on('mouseenter', handleMouseEnter);
return () => {
map.off('click', handleMapClick);
map.off('mousedown', handleMouseDown);
map.off('mouseenter', handleMouseEnter);
canvas.style.cursor = oldCursor;
};
}, [map, active]);
// Handle Inspector Clear on deactivate
useEffect(() => {
if (!map || !active) {
markerRef.current?.remove();
markerRef.current = null;
setInspectedGeojson(null);
setInspectedHierarchy(null);
setInspectedPoint(null);
return;
}
if (!markerRef.current) {
markerRef.current = new maplibregl.Marker({ element: createMarkerEl(), draggable: true, anchor: 'bottom' });
markerRef.current.on('dragend', async () => {
if (!markerRef.current) return;
const { lat, lng } = markerRef.current.getLngLat();
await performInspection(lat, lng);
});
}
}, [map, active]);
const performInspection = async (lat: number, lng: number, ctrlKey: boolean = false) => {
@ -219,13 +241,6 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
}
try {
if (!markerRef.current) return;
markerRef.current.setLngLat([lng, lat]);
try {
markerRef.current.addTo(map!);
} catch (e) { }
if (id === inspectionIdRef.current) {
setInspectedPoint({ lat, lng });
setLoadingInspector(true);
@ -589,10 +604,6 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
setGeojsons({});
setInspectedGeojson(null);
setInspectedHierarchy(null);
if (markerRef.current) {
markerRef.current.remove();
markerRef.current = null;
}
};
// Import initialRegions prop (same logic as handleImportJson)
@ -611,7 +622,7 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
// Check if current selectedRegions already contains exactly these gids
const currentGids = selectedRegions.map(r => r.gid).sort().join(',');
if (currentGids === key && importedInitialRef.current !== null) {
if (currentGids === key) {
importedInitialRef.current = key;
return;
}

View File

@ -2,16 +2,16 @@ import React, { useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { CompetitorsGridView } from '../CompetitorsGridView';
import { CompetitorsMapView } from '../CompetitorsMapView';
import { CompetitorsThumbView } from '../CompetitorsThumbView';
import { CompetitorsMetaView } from '../CompetitorsMetaView';
import { CompetitorsReportView } from './CompetitorsReportView';
import { CompetitorsGridView } from '../PlacesGridView';
import { PlacesMapView } from '../PlacesMapView';
import { PlacesThumbView } from '../PlacesThumbView';
import { PlacesMetaView } from '../PlacesMetaView';
import { PlacesReportView } from './PlacesReportView';
import { useRestoredSearch } from './RestoredSearchContext';
import { expandPlacesGridSearch } from '../client-gridsearch';
import { POSTER_THEMES } from '../utils/poster-themes';
import { type CompetitorFull } from '@polymech/shared';
import { type PlaceFull } from '@polymech/shared';
import { type LogEntry } from '@/contexts/LogContext';
import ChatLogBrowser from '@/components/ChatLogBrowser';
import { GripVertical } from 'lucide-react';
@ -21,7 +21,7 @@ type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log' | 'poster';
interface GridSearchResultsProps {
jobId: string;
competitors: CompetitorFull[];
competitors: PlaceFull[];
excludedTypes: string[];
updateExcludedTypes?: (types: string[]) => Promise<void>;
liveAreas?: any[];
@ -139,7 +139,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
const handleSelectPlace = useCallback((id: string | null, behavior: 'select' | 'open' | 'toggle' = 'select') => {
const isSame = id === selectedPlaceId;
setSelectedPlaceId(id);
if (behavior === 'open') {
setShowDetails(true);
} else if (behavior === 'toggle') {
@ -182,10 +182,10 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove debounce reset (prevented URL update)');
clearTimeout(mapMoveTimerRef.current);
}
mapMoveTimerRef.current = setTimeout(() => {
if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove → window.history.replaceState committed (300ms debounce)');
// USE MANUAL HISTORY API: Updates the URL bar silently WITHOUT triggering React re-renders/useSearchParams
const url = new URL(window.location.href);
url.searchParams.set('mapLat', state.lat.toFixed(6));
@ -193,7 +193,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
url.searchParams.set('mapZoom', state.zoom.toFixed(2));
if (state.pitch !== undefined) url.searchParams.set('mapPitch', state.pitch.toFixed(0));
if (state.bearing !== undefined) url.searchParams.set('mapBearing', state.bearing.toFixed(0));
// Maintain view state if it's not locked to 'poster'
if (url.searchParams.get('view') !== 'poster') {
url.searchParams.set('view', 'map');
@ -360,17 +360,17 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
)}
{viewMode === 'thumb' && (
<CompetitorsThumbView
competitors={filteredCompetitors}
<PlacesThumbView
places={filteredCompetitors}
filters={[]}
toggleFilter={() => { }}
/>
)}
{(viewMode === 'map' || viewMode === 'poster') && (
<CompetitorsMapView
<PlacesMapView
preset="SearchView"
competitors={filteredCompetitors}
places={filteredCompetitors}
isPosterMode={viewMode === 'poster'}
posterTheme={posterTheme}
setPosterTheme={setPosterTheme}
@ -412,15 +412,15 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
)}
{viewMode === 'meta' && (
<CompetitorsMetaView
competitors={filteredCompetitors}
<PlacesMetaView
places={filteredCompetitors}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
/>
)}
{viewMode === 'report' && (
<CompetitorsReportView jobId={jobId} />
<PlacesReportView jobId={jobId} />
)}
{viewMode === 'log' && import.meta.env.DEV && sseLogs && (
@ -446,8 +446,8 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
</div>
{/* Property Pane */}
<div
className="h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0 border-l border-gray-200 dark:border-gray-700"
<div
className="h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0 border-l border-gray-200 dark:border-gray-700"
style={{ width: sidebarWidth }}
>
<LocationDetailView

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Loader2, Search, MapPin, CheckCircle, ChevronRight, ChevronLeft, Settings } from 'lucide-react';
import { submitPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, getPlacesTypes } from '../client-gridsearch';
import { CompetitorsMapView } from '../CompetitorsMapView';
import { PlacesMapView } from '../PlacesMapView';
import { GadmRegionCollector } from '../gadm-picker/GadmRegionCollector';
import { GadmPickerProvider, useGadmPicker } from '../gadm-picker/GadmPickerContext';
import { Badge } from "@/components/ui/badge";
@ -88,7 +88,6 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
const handleNext = async () => {
if (step === 1 && collectedNodes.length === 0) return;
if (step === 2 && !searchQuery.trim()) return;
if (step === 1) { setStep(2); return; }
@ -244,9 +243,9 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100"><T>Preview & Simulate</T></h2>
</div>
<div className="border rounded-xl overflow-hidden flex-1 min-h-0 mt-3 flex flex-col">
<CompetitorsMapView
<PlacesMapView
preset="Minimal"
competitors={[]}
places={[]}
onMapCenterUpdate={() => { }}
enrich={async () => { }}
isEnriching={false}
@ -424,7 +423,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
{step < 4 ? (
<button
onClick={handleNext}
disabled={(step === 1 && collectedNodes.length === 0) || (step === 2 && !searchQuery.trim())}
disabled={(step === 2 && !searchQuery.trim())}
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
<T>Continue</T> <ChevronRight className="w-5 h-5 ml-1" />
@ -432,7 +431,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
) : (
<button
onClick={handleSubmit}
disabled={submitting}
disabled={submitting || collectedNodes.length === 0}
className="flex items-center bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-2.5 rounded-xl font-medium transition-colors disabled:opacity-50"
>
{submitting ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : null}

View File

@ -3,22 +3,22 @@ import { Loader2, AlertCircle, Download } from 'lucide-react';
import MarkdownRenderer from '@/components/MarkdownRenderer';
import { fetchPlacesGridSearchExport } from '../client-gridsearch';
export function CompetitorsReportView({ jobId }: { jobId: string }) {
export function PlacesReportView({ jobId }: { jobId: string }) {
const [markdown, setMarkdown] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!jobId) return;
let active = true;
const load = async () => {
try {
setLoading(true);
setError(null);
const text = await fetchPlacesGridSearchExport(jobId, 'md');
if (active) {
setMarkdown(text);
}

View File

@ -4,7 +4,7 @@ import type { GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
import { Globe, Instagram, Facebook, Linkedin, Youtube, Twitter, Github, Star } from 'lucide-react';
import { EmailCell } from './EmailCell';
import { TypeCell } from './TypeCell';
import type { CompetitorSettings } from './useCompetitorSettings';
import type { CompetitorSettings } from './usePlacesSettings';
import { T, translate } from '../../i18n';
export type GridPreset = 'full' | 'min';
@ -16,14 +16,14 @@ interface UseGridColumnsProps {
export const getPresetVisibilityModel = (preset: GridPreset) => {
const allFields = [
'thumbnail', 'title', 'email', 'phone', 'address',
'thumbnail', 'title', 'email', 'phone', 'address',
'city', 'country', 'website', 'rating', 'types', 'social'
];
const minFields = ['thumbnail', 'title', 'types', 'social', 'rating'];
const model: Record<string, boolean> = {};
if (preset === 'min') {
allFields.forEach(f => {
model[f] = minFields.includes(f);
@ -34,7 +34,7 @@ export const getPresetVisibilityModel = (preset: GridPreset) => {
model[f] = f !== 'city'; // default full has no city
});
}
return model;
};

View File

@ -1,19 +1,18 @@
import { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../../hooks/useAuth';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { supabase } from '@/integrations/supabase/client';
import { translate } from '../../i18n';
import { translate } from '@/i18n';
export interface CompetitorSettings {
export interface PlacesSettings {
known_types: string[];
excluded_types: string[];
}
export const useCompetitorSettings = () => {
export const usePlacesSettings = () => {
const { user } = useAuth();
const userId = user?.id;
const [settings, setSettings] = useState<CompetitorSettings>({ known_types: [], excluded_types: [] });
const [settings, setSettings] = useState<PlacesSettings>({ known_types: [], excluded_types: [] });
const [loading, setLoading] = useState(false);
const fetchSettings = useCallback(async () => {