map fixes
This commit is contained in:
parent
91c6412491
commit
ecf2cb1836
662
packages/ui/docs/acl.md
Normal file
662
packages/ui/docs/acl.md
Normal 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
|
||||
```
|
||||
@ -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`).
|
||||
@ -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.
|
||||
@ -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.
|
||||
@ -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`
|
||||
@ -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>
|
||||
@ -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.
|
||||
|
||||
52
packages/ui/docs/products-crud.md
Normal file
52
packages/ui/docs/products-crud.md
Normal 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.
|
||||
69
packages/ui/docs/products.md
Normal file
69
packages/ui/docs/products.md
Normal 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;
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
210
packages/ui/src/components/widgets/useCompetitorsMapData.ts
Normal file
210
packages/ui/src/components/widgets/useCompetitorsMapData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
@ -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>
|
||||
@ -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 || [];
|
||||
@ -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,
|
||||
@ -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>
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
Loading…
Reference in New Issue
Block a user