six-degrees 0.4.9 → 0.4.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/social/crm/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/social/crm/route/build-manifest.json +12 -0
- package/.next/standalone/.next/server/app/api/social/crm/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/social/crm/route.js +10 -0
- package/.next/standalone/.next/server/app/api/social/crm/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/social/crm/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/social/crm/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/social/messages/route.js +4 -3
- package/.next/standalone/.next/server/app/api/social/messages/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/social/route.js +4 -3
- package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/import.html +1 -1
- package/.next/standalone/.next/server/app/import.rsc +15 -15
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/launch.html +2 -2
- package/.next/standalone/.next/server/app/launch.rsc +15 -15
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths.html +1 -1
- package/.next/standalone/.next/server/app/paths.rsc +15 -15
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/profile.html +1 -1
- package/.next/standalone/.next/server/app/profile.rsc +15 -15
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/queue.html +1 -1
- package/.next/standalone/.next/server/app/queue.rsc +15 -15
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/scores.html +1 -1
- package/.next/standalone/.next/server/app/scores.rsc +15 -15
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +15 -15
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup.html +1 -1
- package/.next/standalone/.next/server/app/setup.rsc +15 -15
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/social.html +1 -1
- package/.next/standalone/.next/server/app/social.rsc +15 -15
- package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__06mi9k2._.js → [root-of-the-server]__05_3-22._.js} +7 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t3v1a1._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tir0vc._.js +8 -0
- package/.next/standalone/.next/server/chunks/_0ea9qwy._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1_fynia._.js +4 -4
- package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1oqfuh8._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1xh07bp._.js +1 -1
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_social_crm_route_actions_0d1aflv.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +7 -7
- package/.next/standalone/.next/server/chunks/lib_linkedin-export_12imqxg.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_05gt5ya.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0-85h6g._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_08cfj2b._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0c_y09g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_profile_page_0u3-l5y.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_queue_page_0c6znd6.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_scores_page_09wepvv.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_page_1f8mjj_.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_social_page_0h7zxda.js +2 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_galaxy-lab_108_lu-.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/0a5iadnmt9u9k.js +1 -0
- package/.next/standalone/.next/static/chunks/15sjx07xhpn6p.js +1 -0
- package/.next/standalone/.next/static/chunks/{054zdthcnyd2h.js → 160wjb73ddwqd.js} +1 -1
- package/.next/standalone/.next/static/chunks/1a74dky22dxd6.js +1 -0
- package/.next/standalone/.next/static/chunks/{04bbi55urkrd6.js → 1c_n0973t07l0.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0bo1dxn5gwzpk.css → 1x_ysly42-teq.css} +1 -1
- package/.next/standalone/.next/static/chunks/{1_mohm2643nxp.js → 236f0jtp68j_h.js} +1 -1
- package/.next/standalone/.next/static/chunks/297e1z34yi--z.js +1 -0
- package/.next/standalone/.next/static/chunks/2_ak6qjxb6oa5.js +1 -0
- package/.next/standalone/.next/static/chunks/{38wwtni5e1ec1.js → 39hr8d-b2ibtw.js} +1 -1
- package/.next/standalone/.next/static/chunks/3ui4d-uqkqj5d.js +2 -0
- package/.next/{static/chunks/1z_rt4jteewqp.js → standalone/.next/static/chunks/3zw8bv7-mc4n6.js} +1 -1
- package/.next/standalone/CHANGELOG.md +57 -0
- package/.next/standalone/app/api/data/export/route.js +2 -1
- package/.next/standalone/app/api/scraper/route.js +5 -1
- package/.next/standalone/app/api/social/crm/route.js +37 -0
- package/.next/standalone/app/api/social/messages/route.js +44 -8
- package/.next/standalone/app/api/social/route.js +80 -22
- package/.next/standalone/app/components/GalaxyLab.js +4 -1
- package/.next/standalone/app/components/ScanStatusBar.js +97 -71
- package/.next/standalone/app/components/settings/DataSection.js +12 -3
- package/.next/standalone/app/globals.css +15 -0
- package/.next/standalone/app/paths/page.js +0 -2
- package/.next/standalone/app/profile/page.js +0 -2
- package/.next/standalone/app/queue/page.js +0 -2
- package/.next/standalone/app/scores/page.js +0 -2
- package/.next/standalone/app/settings/page.js +0 -2
- package/.next/standalone/app/setup/page.js +0 -2
- package/.next/standalone/app/social/Crm.js +683 -0
- package/.next/standalone/app/social/Thread.js +90 -0
- package/.next/standalone/app/social/page.js +71 -62
- package/.next/standalone/lib/data-export.js +10 -4
- package/.next/standalone/lib/data-folder.js +9 -0
- package/.next/standalone/lib/data-import.js +13 -3
- package/.next/standalone/lib/island.js +36 -0
- package/.next/standalone/lib/linkedin-export.js +267 -46
- package/.next/standalone/lib/social-crm.js +415 -0
- package/.next/standalone/lib/social-store.js +13 -6
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/scripts/scrape.py +126 -22
- package/.next/static/chunks/0a5iadnmt9u9k.js +1 -0
- package/.next/static/chunks/15sjx07xhpn6p.js +1 -0
- package/.next/static/chunks/{054zdthcnyd2h.js → 160wjb73ddwqd.js} +1 -1
- package/.next/static/chunks/1a74dky22dxd6.js +1 -0
- package/.next/static/chunks/{04bbi55urkrd6.js → 1c_n0973t07l0.js} +1 -1
- package/.next/static/chunks/{0bo1dxn5gwzpk.css → 1x_ysly42-teq.css} +1 -1
- package/.next/static/chunks/{1_mohm2643nxp.js → 236f0jtp68j_h.js} +1 -1
- package/.next/static/chunks/297e1z34yi--z.js +1 -0
- package/.next/static/chunks/2_ak6qjxb6oa5.js +1 -0
- package/.next/static/chunks/{38wwtni5e1ec1.js → 39hr8d-b2ibtw.js} +1 -1
- package/.next/static/chunks/3ui4d-uqkqj5d.js +2 -0
- package/.next/{standalone/.next/static/chunks/1z_rt4jteewqp.js → static/chunks/3zw8bv7-mc4n6.js} +1 -1
- package/CHANGELOG.md +57 -0
- package/lib/data-export.js +10 -4
- package/lib/data-folder.js +9 -0
- package/lib/data-import.js +13 -3
- package/lib/island.js +36 -0
- package/lib/linkedin-export.js +267 -46
- package/lib/social-crm.js +415 -0
- package/lib/social-store.js +13 -6
- package/package.json +1 -1
- package/scripts/scrape.py +126 -22
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08f5awg._.js +0 -8
- package/.next/standalone/.next/server/chunks/lib_18cr1gi._.js +0 -8
- package/.next/standalone/.next/server/chunks/ssr/_0ezj4e0._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1errbuf._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/app_components_ui_0pdmz2z.js +0 -3
- package/.next/standalone/.next/static/chunks/2bftn9gubwf0w.js +0 -1
- package/.next/standalone/.next/static/chunks/2pw6_osz1n_te.js +0 -1
- package/.next/standalone/.next/static/chunks/39csdcx7yv227.js +0 -1
- package/.next/standalone/.next/static/chunks/3yzlf70j5t0-o.js +0 -1
- package/.next/standalone/.next/static/chunks/3z-swu5pet507.js +0 -1
- package/.next/standalone/app/social/Conversations.js +0 -246
- package/.next/static/chunks/2bftn9gubwf0w.js +0 -1
- package/.next/static/chunks/2pw6_osz1n_te.js +0 -1
- package/.next/static/chunks/39csdcx7yv227.js +0 -1
- package/.next/static/chunks/3yzlf70j5t0-o.js +0 -1
- package/.next/static/chunks/3z-swu5pet507.js +0 -1
- /package/.next/standalone/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_ssgManifest.js +0 -0
- /package/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_buildManifest.js +0 -0
- /package/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The Social tab's CRM: everyone you've been in touch with on LinkedIn, one
|
|
4
|
+
// contact each (lib/social-crm.js), and your own stage, tags, notes and next
|
|
5
|
+
// follow-up for them (app/api/social/crm). A list on the left with the views
|
|
6
|
+
// and a search box, the person on the right; stacked on a phone.
|
|
7
|
+
//
|
|
8
|
+
// What it reads: the Conversations list and requests (GET /api/social/messages,
|
|
9
|
+
// no words), your network (for tier, company and who can introduce you), and
|
|
10
|
+
// the CRM file. What was said shows only while Keep my messages is on (Thread).
|
|
11
|
+
// Your notes are always kept: they're yours. Nothing here goes anywhere but
|
|
12
|
+
// this computer; the CSV is made in this window and saved where you choose.
|
|
13
|
+
|
|
14
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
buildContacts, inboxOf, awaitingOf, followUpsDue, pipelineOf, sentOf, receivedOf, suggestedStage, matchesContact,
|
|
17
|
+
crmCsv, localDay, emptyCrm, STAGES, stageLabel, AWAIT_DAYS,
|
|
18
|
+
} from '../../lib/social-crm';
|
|
19
|
+
import { keyFor } from '../../lib/separation';
|
|
20
|
+
import { Body, LINE } from '../components/ui';
|
|
21
|
+
import Thread from './Thread';
|
|
22
|
+
|
|
23
|
+
const TIER = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
|
|
24
|
+
const STAGE_COLOUR = { new: '#8b9a9a', contacted: '#3498DB', replied: '#00d4aa', meeting: '#ff9f43', won: '#FFD700', 'not-now': '#667' };
|
|
25
|
+
const LIST_PAGE = 100;
|
|
26
|
+
const DAY = 86400000;
|
|
27
|
+
const day = (t) => (t ? new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '');
|
|
28
|
+
const ymd = (s) => (s ? new Date(`${s}T12:00:00`).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '');
|
|
29
|
+
// Only LinkedIn's own pages go behind "Open on LinkedIn".
|
|
30
|
+
const linkedin = (u) => (/^https:\/\/(www\.)?linkedin\.com\//.test(String(u || '')) ? u : null);
|
|
31
|
+
const plural = (n, one, many = `${one}s`) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
|
|
32
|
+
|
|
33
|
+
const VIEWS = [
|
|
34
|
+
['inbox', 'Inbox'],
|
|
35
|
+
['awaiting', 'Awaiting reply'],
|
|
36
|
+
['due', 'Follow-ups due'],
|
|
37
|
+
['pipeline', 'Pipeline'],
|
|
38
|
+
['sent', 'Sent'],
|
|
39
|
+
['received', 'Received'],
|
|
40
|
+
['all', 'All'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const chip = (bg, color = '#dfe8e8') => ({ fontSize: 10.5, padding: '1px 6px', borderRadius: 4, background: bg, color, whiteSpace: 'nowrap' });
|
|
44
|
+
const btn = { background: 'rgba(255,255,255,0.06)', border: LINE, borderRadius: 6, color: '#cfe6f7', cursor: 'pointer', fontSize: 12, padding: '4px 10px' };
|
|
45
|
+
const field = { padding: '6px 10px', borderRadius: 6, border: LINE, background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: 13 };
|
|
46
|
+
|
|
47
|
+
/** A download made in this window, as the app's other exports are (lib/galaxy-export.js). */
|
|
48
|
+
function save(text, name) {
|
|
49
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }));
|
|
50
|
+
const a = document.createElement('a');
|
|
51
|
+
a.href = url;
|
|
52
|
+
a.download = name;
|
|
53
|
+
// Anything appended to <body> is position: fixed (TRAPS §29). Gone at once.
|
|
54
|
+
a.style.position = 'fixed';
|
|
55
|
+
a.style.left = '-9999px';
|
|
56
|
+
document.body.appendChild(a);
|
|
57
|
+
a.click();
|
|
58
|
+
a.remove();
|
|
59
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function Crm({ net, asOf, keep, onKeep, switching, token, crmToken }) {
|
|
63
|
+
const [data, setData] = useState(null);
|
|
64
|
+
const [crm, setCrm] = useState(null);
|
|
65
|
+
const [view, setView] = useState('inbox');
|
|
66
|
+
const [q, setQ] = useState('');
|
|
67
|
+
const [hits, setHits] = useState(null);
|
|
68
|
+
const [picked, setPicked] = useState(null); // { type: 'person', key } | { type: 'group', id }
|
|
69
|
+
const [shown, setShown] = useState(LIST_PAGE);
|
|
70
|
+
const [note, setNote] = useState(null);
|
|
71
|
+
const panel = useRef(null);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
let live = true;
|
|
75
|
+
fetch('/api/social/messages').then((r) => r.json()).then((d) => { if (live) setData(d); })
|
|
76
|
+
.catch(() => { if (live) setData({ conversations: [], invitations: [], names: {}, inviteNotes: {} }); });
|
|
77
|
+
return () => { live = false; };
|
|
78
|
+
}, [token, keep]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
let live = true;
|
|
81
|
+
fetch('/api/social/crm').then((r) => r.json()).then((d) => { if (live) setCrm(d.crm || emptyCrm()); })
|
|
82
|
+
.catch(() => { if (live) setCrm(emptyCrm()); });
|
|
83
|
+
return () => { live = false; };
|
|
84
|
+
}, [crmToken]);
|
|
85
|
+
|
|
86
|
+
// Searching the words happens where they're kept, a moment after typing stops.
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const words = q.trim();
|
|
89
|
+
if (!keep || words.length < 2) return undefined;
|
|
90
|
+
let live = true;
|
|
91
|
+
const t = setTimeout(() => {
|
|
92
|
+
fetch(`/api/social/messages?q=${encodeURIComponent(words)}`).then((r) => r.json())
|
|
93
|
+
.then((d) => { if (live) setHits({ q: words, ids: new Set(d.ids || []) }); }).catch(() => {});
|
|
94
|
+
}, 250);
|
|
95
|
+
return () => { live = false; clearTimeout(t); };
|
|
96
|
+
}, [q, keep, token]);
|
|
97
|
+
|
|
98
|
+
const pickedKey = picked?.type === 'person' ? picked.key : null;
|
|
99
|
+
const { contacts, groups } = useMemo(() => buildContacts({
|
|
100
|
+
conversations: data?.conversations || [],
|
|
101
|
+
invitations: data?.invitations || [],
|
|
102
|
+
network: net || {},
|
|
103
|
+
crm: crm || emptyCrm(),
|
|
104
|
+
names: data?.names || {},
|
|
105
|
+
also: pickedKey ? [pickedKey] : [],
|
|
106
|
+
}), [data, net, crm, pickedKey]);
|
|
107
|
+
|
|
108
|
+
// Anyone's name: a contact's, else the network's, else as the export or the sync spelt it.
|
|
109
|
+
const nameOf = useMemo(() => {
|
|
110
|
+
const byKey = new Map();
|
|
111
|
+
for (const p of [...(net?.degree1 || []), ...(net?.degree2 || [])]) if (!byKey.has(keyFor(p))) byKey.set(keyFor(p), p);
|
|
112
|
+
const names = data?.names || {};
|
|
113
|
+
return (k) => contacts.get(k)?.name || byKey.get(k)?.name || names[k] || null;
|
|
114
|
+
}, [contacts, net, data]);
|
|
115
|
+
|
|
116
|
+
const awaitDays = crm?.settings?.awaitDays || AWAIT_DAYS;
|
|
117
|
+
// "Now" for a page with no data yet, and today for follow-ups: when the page opened.
|
|
118
|
+
const [opened] = useState(() => Date.now());
|
|
119
|
+
const now = asOf || opened;
|
|
120
|
+
const today = localDay(new Date(opened));
|
|
121
|
+
const people = useMemo(() => [...contacts.values()], [contacts]);
|
|
122
|
+
|
|
123
|
+
// Each view as rows of { type, key | id, sub }: what the list shows under the name.
|
|
124
|
+
const views = useMemo(() => {
|
|
125
|
+
const person = (c, sub) => ({ type: 'person', key: c.key, c, sub, t: c.lastContact || 0 });
|
|
126
|
+
const wrote = (c) => (c.lastFromThem == null ? '' : c.lastFromThem ? `They wrote ${day(c.last)}` : `You wrote ${day(c.last)}`);
|
|
127
|
+
const sentText = (s) => s.items.map((i) => (i.type === 'invite' ? `Request ${day(i.t)} · ${i.status}`
|
|
128
|
+
: i.type === 'request' ? `Tracked request · ${i.status}` : `No reply for ${plural(i.waited, 'day')}`)).join(' · ');
|
|
129
|
+
// In All, the most telling thing about each: the last word, else a request, else your stage.
|
|
130
|
+
const gist = (c) => wrote(c)
|
|
131
|
+
|| (c.invitesOut[0] ? `You asked to connect ${day(c.invitesOut[0].t)}` : '')
|
|
132
|
+
|| (c.invitesIn[0] ? `Asked to connect ${day(c.invitesIn[0].t)}` : '')
|
|
133
|
+
|| (c.request ? `Tracked request · ${c.request.status}` : '')
|
|
134
|
+
|| (c.crm?.stage ? stageLabel(c.crm.stage) : '');
|
|
135
|
+
const all = people.filter((c) => c.lastContact || c.crm || c.request).map((c) => person(c, gist(c)));
|
|
136
|
+
for (const g of groups) all.push({ type: 'group', id: g.id, g, sub: g.last ? `${g.lastFromThem ? 'Someone wrote' : 'You wrote'} ${day(g.last)}` : '', t: g.last || 0 });
|
|
137
|
+
all.sort((a, b) => b.t - a.t);
|
|
138
|
+
return {
|
|
139
|
+
inbox: inboxOf(people).map((c) => person(c, c.unread > 0 ? `${c.unread} unread · ${wrote(c)}` : wrote(c))),
|
|
140
|
+
awaiting: awaitingOf(people, now, awaitDays).map((a) => person(a.contact, `You wrote ${day(a.contact.last)}, no reply for ${plural(a.waited, 'day')}`)),
|
|
141
|
+
due: followUpsDue(people, today).map((c) => person(c, c.crm.followUp === today ? 'Follow up today' : `Follow up was due ${ymd(c.crm.followUp)}`)),
|
|
142
|
+
pipeline: pipelineOf(people),
|
|
143
|
+
sent: sentOf(people, now, awaitDays).map((s) => person(s.contact, sentText(s))),
|
|
144
|
+
received: receivedOf(people).map((r) => person(r.contact, `Asked to connect ${day(r.t)}`)),
|
|
145
|
+
all,
|
|
146
|
+
};
|
|
147
|
+
}, [people, groups, now, awaitDays, today]);
|
|
148
|
+
|
|
149
|
+
const said = keep && hits?.q === q.trim() ? hits.ids : null;
|
|
150
|
+
const words = q.trim().toLowerCase();
|
|
151
|
+
const rows = useMemo(() => {
|
|
152
|
+
const list = view === 'pipeline' ? [] : views[view];
|
|
153
|
+
if (!words) return list;
|
|
154
|
+
const found = list.filter((r) => (r.type === 'person'
|
|
155
|
+
? matchesContact(r.c, words, said)
|
|
156
|
+
: String(r.g.title || '').toLowerCase().includes(words) || r.g.people.some((k) => String(nameOf(k) || '').toLowerCase().includes(words)) || said?.has(r.g.id)));
|
|
157
|
+
// In All, anyone in your network matches too, so you can add a note to
|
|
158
|
+
// someone you haven't been in touch with yet.
|
|
159
|
+
if (view === 'all' && words.length >= 2 && net) {
|
|
160
|
+
const have = new Set(found.map((r) => r.key));
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
for (const p of [...(net.degree1 || []), ...(net.degree2 || [])]) {
|
|
163
|
+
const k = keyFor(p);
|
|
164
|
+
if (have.has(k) || seen.has(k)) continue;
|
|
165
|
+
if (![p.name, p.company, p.role, p.headline].some((x) => String(x || '').toLowerCase().includes(words))) continue;
|
|
166
|
+
seen.add(k);
|
|
167
|
+
found.push({ type: 'person', key: k, c: null, row: p, sub: 'Nothing on file yet', t: 0 });
|
|
168
|
+
if (seen.size >= 50) break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}, [views, view, words, said, net, nameOf]);
|
|
173
|
+
|
|
174
|
+
const pipeline = useMemo(() => {
|
|
175
|
+
if (view !== 'pipeline') return null;
|
|
176
|
+
const out = {};
|
|
177
|
+
for (const [id, list] of Object.entries(views.pipeline)) out[id] = words ? list.filter((c) => matchesContact(c, words, said)) : list;
|
|
178
|
+
return out;
|
|
179
|
+
}, [views, view, words, said]);
|
|
180
|
+
|
|
181
|
+
const counts = {
|
|
182
|
+
inbox: views.inbox.length,
|
|
183
|
+
awaiting: views.awaiting.length,
|
|
184
|
+
due: views.due.length,
|
|
185
|
+
pipeline: Object.values(views.pipeline).reduce((n, l) => n + l.length, 0),
|
|
186
|
+
sent: views.sent.length,
|
|
187
|
+
received: views.received.length,
|
|
188
|
+
all: views.all.length,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
function pick(p) {
|
|
192
|
+
setPicked(p);
|
|
193
|
+
// Stacked (a phone): the person's panel is below the list, so go there.
|
|
194
|
+
if (typeof window !== 'undefined' && window.matchMedia?.('(max-width: 760px)').matches) {
|
|
195
|
+
setTimeout(() => panel.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function change(key, set) {
|
|
200
|
+
const r = await fetch('/api/social/crm', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, set }) });
|
|
201
|
+
const d = await r.json().catch(() => ({}));
|
|
202
|
+
if (!r.ok) throw new Error(d.error || 'That couldn’t be saved.');
|
|
203
|
+
setCrm((cur) => {
|
|
204
|
+
const next = { ...(cur || emptyCrm()), people: { ...(cur?.people || {}) } };
|
|
205
|
+
if (d.entry) next.people[key] = d.entry;
|
|
206
|
+
else delete next.people[key];
|
|
207
|
+
return next;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function setAwaitDays(n) {
|
|
212
|
+
const r = await fetch('/api/social/crm', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ settings: { awaitDays: n } }) });
|
|
213
|
+
const d = await r.json().catch(() => ({}));
|
|
214
|
+
if (r.ok) setCrm((cur) => ({ ...(cur || emptyCrm()), settings: d.settings }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Your notes can outlive Forget it (it asks); this is the way to delete them on their own.
|
|
218
|
+
async function deleteNotes() {
|
|
219
|
+
if (!window.confirm('Delete your CRM: every stage, tag, note and follow-up date you added? This can’t be undone.')) return;
|
|
220
|
+
await fetch('/api/social/crm', { method: 'DELETE' });
|
|
221
|
+
setCrm(emptyCrm());
|
|
222
|
+
setNote('Your CRM notes are deleted.');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function exportCsv() {
|
|
226
|
+
const list = people.filter((c) => c.kind !== 'sponsored').sort((a, b) => (b.lastContact || 0) - (a.lastContact || 0));
|
|
227
|
+
const name = `Six Degrees CRM ${localDay()}.csv`;
|
|
228
|
+
save(crmCsv(list), name);
|
|
229
|
+
// The page can't see where the file went: a browser puts it in its
|
|
230
|
+
// downloads folder, the Mac app asks where to save it.
|
|
231
|
+
setNote(`Made “${name}” (${plural(list.length, 'person', 'people')}). Look for it in your Downloads folder, or wherever you chose to save it. It holds your notes: keep it private.`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const loading = !data || !crm || !net;
|
|
235
|
+
const box = { padding: '12px 14px', borderRadius: 8, border: LINE, background: 'rgba(255,255,255,0.03)', marginTop: 10 };
|
|
236
|
+
const selectedGroup = picked?.type === 'group' ? groups.find((g) => g.id === picked.id) : null;
|
|
237
|
+
const selected = pickedKey ? contacts.get(pickedKey) : null;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<>
|
|
241
|
+
<div style={box}>
|
|
242
|
+
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13.5, color: '#fff', cursor: switching ? 'default' : 'pointer' }}>
|
|
243
|
+
<input type="checkbox" checked={keep} disabled={switching} onChange={(e) => onKeep(e.target.checked)} />
|
|
244
|
+
<b>Keep my messages on this computer</b>
|
|
245
|
+
<span style={{ fontSize: 11.5, color: keep ? '#00ff88' : '#8b9a9a' }}>{keep ? 'on' : 'off'}</span>
|
|
246
|
+
</label>
|
|
247
|
+
<Body style={{ margin: '6px 0 0', fontSize: 12.5 }}>
|
|
248
|
+
Off unless you turn it on. When it’s on, the messages themselves, including what other people wrote to
|
|
249
|
+
you, and the notes sent with connection requests, are saved only in the app’s data folder on this
|
|
250
|
+
computer. They’re never sent anywhere, and they aren’t in “Save a copy of my
|
|
251
|
+
network”. <i>Forget it</i>, or switching this off, deletes them. With it off, the CRM still shows who,
|
|
252
|
+
when and who wrote last. Your own stages, tags, notes and follow-ups are always kept, on this computer only.
|
|
253
|
+
</Body>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div className="crm-grid" style={{ marginTop: 10 }}>
|
|
257
|
+
<div className={view === 'pipeline' ? 'crm-wide' : undefined} style={{ ...box, marginTop: 0, padding: 0, minWidth: 0 }}>
|
|
258
|
+
<div role="tablist" aria-label="CRM views" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, padding: '10px 10px 6px', borderBottom: LINE }}>
|
|
259
|
+
{VIEWS.map(([id, label]) => (
|
|
260
|
+
<button
|
|
261
|
+
key={id}
|
|
262
|
+
role="tab"
|
|
263
|
+
aria-selected={view === id}
|
|
264
|
+
onClick={() => { setView(id); setShown(LIST_PAGE); }}
|
|
265
|
+
style={{
|
|
266
|
+
...btn, padding: '4px 9px', fontSize: 12,
|
|
267
|
+
background: view === id ? 'rgba(52,152,219,0.28)' : 'rgba(255,255,255,0.04)',
|
|
268
|
+
color: view === id ? '#fff' : '#b8c4c4',
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
{label} <span style={{ color: view === id ? '#cfe6f7' : '#778' }}>{loading ? '' : counts[id].toLocaleString()}</span>
|
|
272
|
+
</button>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
<div style={{ padding: '8px 10px', borderBottom: LINE, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
276
|
+
<input
|
|
277
|
+
type="search"
|
|
278
|
+
value={q}
|
|
279
|
+
onChange={(e) => { setQ(e.target.value); setShown(LIST_PAGE); }}
|
|
280
|
+
placeholder={keep ? 'Search names, companies, tags, notes, what was said' : 'Search names, companies, tags, notes'}
|
|
281
|
+
aria-label="Search the CRM"
|
|
282
|
+
style={{ ...field, flex: '1 1 180px', minWidth: 0 }}
|
|
283
|
+
/>
|
|
284
|
+
<button onClick={exportCsv} disabled={loading} style={btn} title="A spreadsheet of everyone here, with your stages, tags, follow-ups and notes">Export CSV</button>
|
|
285
|
+
</div>
|
|
286
|
+
{view === 'awaiting' && (
|
|
287
|
+
<div style={{ padding: '8px 12px', borderBottom: LINE, fontSize: 12.5, color: '#b8c4c4', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
288
|
+
You wrote last and nothing came back for
|
|
289
|
+
<input
|
|
290
|
+
type="number" min={1} max={365} value={awaitDays} aria-label="Days with no reply"
|
|
291
|
+
onChange={(e) => { const n = Number(e.target.value); if (n >= 1 && n <= 365) setAwaitDays(n); }}
|
|
292
|
+
style={{ ...field, width: 64, padding: '3px 6px' }}
|
|
293
|
+
/>
|
|
294
|
+
days or more{asOf ? `, as of ${day(asOf)}` : ''}.
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
{note && <div style={{ padding: '8px 12px', fontSize: 12.5, color: '#00ff88', borderBottom: LINE }}>{note}</div>}
|
|
298
|
+
|
|
299
|
+
{loading && <div style={{ padding: 12, fontSize: 13, color: '#8b9a9a' }}>Loading…</div>}
|
|
300
|
+
{!loading && view === 'pipeline' && <Board pipeline={pipeline} picked={pickedKey} onPick={(key) => pick({ type: 'person', key })} />}
|
|
301
|
+
{!loading && view !== 'pipeline' && rows.length === 0 && (
|
|
302
|
+
<div style={{ padding: 12, fontSize: 13, color: '#8b9a9a' }}>{words ? 'Nothing matches that.' : EMPTY[view]}</div>
|
|
303
|
+
)}
|
|
304
|
+
{!loading && view !== 'pipeline' && (
|
|
305
|
+
<div>
|
|
306
|
+
{rows.slice(0, shown).map((r) => (
|
|
307
|
+
<Row
|
|
308
|
+
key={r.type === 'person' ? `p:${r.key}` : `g:${r.id}`}
|
|
309
|
+
r={r}
|
|
310
|
+
nameOf={nameOf}
|
|
311
|
+
active={r.type === 'person' ? pickedKey === r.key : picked?.id === r.id}
|
|
312
|
+
onPick={() => pick(r.type === 'person' ? { type: 'person', key: r.key } : { type: 'group', id: r.id })}
|
|
313
|
+
/>
|
|
314
|
+
))}
|
|
315
|
+
{rows.length > shown && (
|
|
316
|
+
<button onClick={() => setShown(shown + LIST_PAGE)} style={{ width: '100%', padding: 10, background: 'none', border: 'none', color: '#3498DB', cursor: 'pointer', fontSize: 13 }}>
|
|
317
|
+
Show {Math.min(LIST_PAGE, rows.length - shown).toLocaleString()} more of {rows.length.toLocaleString()}
|
|
318
|
+
</button>
|
|
319
|
+
)}
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div ref={panel} className={view === 'pipeline' ? 'crm-wide' : undefined} style={{ ...box, marginTop: 0, minWidth: 0, scrollMarginTop: 80 }}>
|
|
325
|
+
{!picked && <div style={{ fontSize: 13, color: '#8b9a9a' }}>Pick someone to see everything with them, and to add a stage, tags, notes or a follow-up.</div>}
|
|
326
|
+
{selected && (
|
|
327
|
+
<Person
|
|
328
|
+
key={selected.key}
|
|
329
|
+
c={selected}
|
|
330
|
+
nameOf={nameOf}
|
|
331
|
+
groups={groups}
|
|
332
|
+
keep={keep}
|
|
333
|
+
token={token}
|
|
334
|
+
inviteNotes={data?.inviteNotes || {}}
|
|
335
|
+
onChange={change}
|
|
336
|
+
onPickGroup={(id) => pick({ type: 'group', id })}
|
|
337
|
+
/>
|
|
338
|
+
)}
|
|
339
|
+
{selectedGroup && (
|
|
340
|
+
<Group g={selectedGroup} contacts={contacts} nameOf={nameOf} keep={keep} token={token} onPick={(key) => pick({ type: 'person', key })} />
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
<div style={{ fontSize: 11.5, color: '#778', marginTop: 6 }}>
|
|
345
|
+
Everyone from your export and the live sync, connections or not; groups are listed and marked. Sponsored
|
|
346
|
+
messages are marked and kept out of the Inbox. Awaiting reply and warmth are judged as of the newest thing
|
|
347
|
+
your data knows.
|
|
348
|
+
{Object.keys(crm?.people || {}).length > 0 && (
|
|
349
|
+
<>
|
|
350
|
+
{' '}Your notes are in the app’s data folder, not in “Save a copy of my network”.{' '}
|
|
351
|
+
<button onClick={deleteNotes} style={{ background: 'none', border: 'none', color: '#ff9b9b', cursor: 'pointer', fontSize: 11.5, padding: 0 }}>Delete my CRM notes</button>
|
|
352
|
+
</>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
</>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const EMPTY = {
|
|
360
|
+
inbox: 'No one is waiting on a reply.',
|
|
361
|
+
awaiting: 'Nobody you wrote to is overdue a reply.',
|
|
362
|
+
due: 'No follow-ups are due. Set one on anyone, and it shows here on the day.',
|
|
363
|
+
sent: 'Nothing sent yet: choose your export folder above (its Invitations.csv), or mark requests sent in the app.',
|
|
364
|
+
received: 'No requests waiting on you, as far as your export shows.',
|
|
365
|
+
all: 'Nobody here yet. Choose your export folder above, or sync your messages.',
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
function Dot({ tier }) {
|
|
369
|
+
return <span style={{ width: 8, height: 8, borderRadius: '50%', background: TIER[tier] || '#556', flexShrink: 0, display: 'inline-block' }} />;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function Badges({ c }) {
|
|
373
|
+
return (
|
|
374
|
+
<>
|
|
375
|
+
{c && !c.connection && <span style={chip('rgba(255,159,67,0.18)', '#ffc58a')}>{c.degree === 2 ? '2nd degree' : 'Not a connection'}</span>}
|
|
376
|
+
{c?.kind === 'sponsored' && <span style={chip('rgba(255,255,255,0.08)')}>Sponsored</span>}
|
|
377
|
+
{c?.kind === 'inmail' && <span style={chip('rgba(155,89,182,0.25)')}>InMail</span>}
|
|
378
|
+
{c?.crm?.stage && <span style={chip('rgba(255,255,255,0.06)', STAGE_COLOUR[c.crm.stage])}>{stageLabel(c.crm.stage)}</span>}
|
|
379
|
+
</>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function Row({ r, nameOf, active, onPick }) {
|
|
384
|
+
let title;
|
|
385
|
+
let tier = null;
|
|
386
|
+
let extra = null;
|
|
387
|
+
if (r.type === 'person') {
|
|
388
|
+
const c = r.c;
|
|
389
|
+
title = c?.name || r.row?.name || r.key;
|
|
390
|
+
tier = c?.row?.tier || r.row?.tier;
|
|
391
|
+
extra = (
|
|
392
|
+
<>
|
|
393
|
+
<Badges c={c || { connection: r.row?.degree === 1, degree: r.row?.degree }} />
|
|
394
|
+
{c?.unread > 0 && <span style={chip('#3498DB', '#fff')}>{c.unread} unread</span>}
|
|
395
|
+
</>
|
|
396
|
+
);
|
|
397
|
+
} else {
|
|
398
|
+
title = groupLabel(r.g, nameOf);
|
|
399
|
+
extra = <span style={chip('rgba(255,255,255,0.08)')}>{r.g.unknown ? 'To someone unnamed' : 'Group'}</span>;
|
|
400
|
+
}
|
|
401
|
+
const company = r.type === 'person' ? (r.c?.row?.company || r.row?.company) : null;
|
|
402
|
+
return (
|
|
403
|
+
<div
|
|
404
|
+
role="button"
|
|
405
|
+
tabIndex={0}
|
|
406
|
+
aria-pressed={active}
|
|
407
|
+
onClick={onPick}
|
|
408
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(); } }}
|
|
409
|
+
style={{ padding: '8px 12px', borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', background: active ? 'rgba(52,152,219,0.14)' : 'none' }}
|
|
410
|
+
>
|
|
411
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, minWidth: 0, flexWrap: 'wrap' }}>
|
|
412
|
+
<Dot tier={tier} />
|
|
413
|
+
<b style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '100%' }}>{title}</b>
|
|
414
|
+
{extra}
|
|
415
|
+
</div>
|
|
416
|
+
<div style={{ fontSize: 11.5, color: '#8b9a9a', marginTop: 2, paddingLeft: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
417
|
+
{[company, r.sub].filter(Boolean).join(' · ')}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function groupLabel(g, nameOf) {
|
|
424
|
+
if (g.title) return g.title;
|
|
425
|
+
if (g.unknown) return 'A conversation only you wrote in';
|
|
426
|
+
const names = g.people.map(nameOf).filter(Boolean);
|
|
427
|
+
return names.length ? `${names.slice(0, 3).join(', ')}${g.people.length > 3 ? ` +${g.people.length - 3}` : ''}` : `${g.people.length} people`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function Board({ pipeline, picked, onPick }) {
|
|
431
|
+
return (
|
|
432
|
+
<div className="crm-board">
|
|
433
|
+
{STAGES.map((s) => (
|
|
434
|
+
<div key={s.id} style={{ minWidth: 0 }}>
|
|
435
|
+
<div style={{ fontSize: 12, fontWeight: 650, color: STAGE_COLOUR[s.id], margin: '0 0 6px', display: 'flex', justifyContent: 'space-between' }}>
|
|
436
|
+
<span>{s.label}</span>
|
|
437
|
+
<span style={{ color: '#778' }}>{pipeline[s.id].length.toLocaleString()}</span>
|
|
438
|
+
</div>
|
|
439
|
+
{pipeline[s.id].length === 0 && <div style={{ fontSize: 11.5, color: '#667', padding: '6px 0' }}>No one</div>}
|
|
440
|
+
{pipeline[s.id].slice(0, 200).map((c) => (
|
|
441
|
+
<div
|
|
442
|
+
key={c.key}
|
|
443
|
+
role="button"
|
|
444
|
+
tabIndex={0}
|
|
445
|
+
onClick={() => onPick(c.key)}
|
|
446
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(c.key); } }}
|
|
447
|
+
style={{
|
|
448
|
+
padding: '7px 9px', marginBottom: 6, borderRadius: 6, border: LINE, cursor: 'pointer', fontSize: 12.5,
|
|
449
|
+
background: picked === c.key ? 'rgba(52,152,219,0.18)' : 'rgba(255,255,255,0.03)',
|
|
450
|
+
}}
|
|
451
|
+
>
|
|
452
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}><Dot tier={c.row?.tier} /><b style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.name}</b></div>
|
|
453
|
+
<div style={{ color: '#8b9a9a', fontSize: 11, marginTop: 2 }}>
|
|
454
|
+
{[c.row?.company, c.crm.followUp ? `Follow up ${ymd(c.crm.followUp)}` : null].filter(Boolean).join(' · ')}
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
))}
|
|
458
|
+
{pipeline[s.id].length > 200 && <div style={{ fontSize: 11, color: '#778' }}>and {(pipeline[s.id].length - 200).toLocaleString()} more (search to find them)</div>}
|
|
459
|
+
</div>
|
|
460
|
+
))}
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const h4 = { fontSize: 12, fontWeight: 650, color: '#b8c4c4', margin: '16px 0 6px', textTransform: 'uppercase', letterSpacing: 0.4 };
|
|
466
|
+
|
|
467
|
+
function Person({ c, nameOf, groups, keep, token, inviteNotes, onChange, onPickGroup }) {
|
|
468
|
+
const row = c.row;
|
|
469
|
+
const [open, setOpen] = useState(c.conversations[0]?.id || null);
|
|
470
|
+
const profile = linkedin(c.profileUrl);
|
|
471
|
+
const thread = linkedin(c.threadUrl);
|
|
472
|
+
const how = c.connection
|
|
473
|
+
? `A connection${row?.connected_date ? ` since ${day(Date.parse(row.connected_date))}` : ''}.`
|
|
474
|
+
: c.degree === 2 ? '2nd degree: not a connection yet.' : 'Not a connection.';
|
|
475
|
+
return (
|
|
476
|
+
<div style={{ fontSize: 13 }}>
|
|
477
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
478
|
+
<Dot tier={row?.tier} />
|
|
479
|
+
<h3 style={{ margin: 0, fontSize: 17 }}>{c.name}</h3>
|
|
480
|
+
{row?.tier && <span style={chip('rgba(255,255,255,0.06)', TIER[row.tier])}>{row.tier} tier</span>}
|
|
481
|
+
<Badges c={c} />
|
|
482
|
+
</div>
|
|
483
|
+
{(row?.headline || row?.company) && (
|
|
484
|
+
<div style={{ color: '#b8c4c4', marginTop: 4 }}>{row.headline || [row.role, row.company].filter(Boolean).join(' at ')}</div>
|
|
485
|
+
)}
|
|
486
|
+
<div style={{ color: '#8b9a9a', marginTop: 4, fontSize: 12.5 }}>
|
|
487
|
+
{how}
|
|
488
|
+
{c.introducers.length > 0 && <> Who can introduce you: <b style={{ color: '#dfe8e8' }}>{c.introducers.slice(0, 6).join(', ')}</b>{c.introducers.length > 6 ? ` and ${c.introducers.length - 6} more` : ''}.</>}
|
|
489
|
+
{row?.unlocked_from_name && c.connection && <> You met through <b style={{ color: '#dfe8e8' }}>{row.unlocked_from_name}</b>.</>}
|
|
490
|
+
</div>
|
|
491
|
+
<div style={{ display: 'flex', gap: 12, marginTop: 6, flexWrap: 'wrap', fontSize: 12.5 }}>
|
|
492
|
+
{profile && <a href={profile} target="_blank" rel="noreferrer" style={{ color: '#3498DB', textDecoration: 'none' }}>Profile on LinkedIn ↗</a>}
|
|
493
|
+
{thread && <a href={thread} target="_blank" rel="noreferrer" style={{ color: '#3498DB', textDecoration: 'none' }}>Conversation on LinkedIn ↗</a>}
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<Yours c={c} onChange={onChange} />
|
|
497
|
+
|
|
498
|
+
<div style={h4}>Conversations</div>
|
|
499
|
+
{c.conversations.length === 0 && <div style={{ color: '#8b9a9a', fontSize: 12.5 }}>None one to one.</div>}
|
|
500
|
+
{c.conversations.map((conv) => (
|
|
501
|
+
<div key={conv.id} style={{ border: LINE, borderRadius: 6, marginBottom: 6, overflow: 'hidden' }}>
|
|
502
|
+
<div
|
|
503
|
+
role="button"
|
|
504
|
+
tabIndex={0}
|
|
505
|
+
aria-expanded={open === conv.id}
|
|
506
|
+
onClick={() => setOpen(open === conv.id ? null : conv.id)}
|
|
507
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(open === conv.id ? null : conv.id); } }}
|
|
508
|
+
style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', padding: '7px 10px', cursor: 'pointer', fontSize: 12.5, background: open === conv.id ? 'rgba(255,255,255,0.04)' : 'none' }}
|
|
509
|
+
>
|
|
510
|
+
<span style={{ color: '#dfe8e8' }}>{day(conv.last) || 'No date'}</span>
|
|
511
|
+
{conv.lastFromThem != null && <span style={{ color: conv.lastFromThem ? '#ff9f43' : '#8b9a9a' }}>{conv.lastFromThem ? 'They wrote last' : 'You wrote last'}</span>}
|
|
512
|
+
{conv.count != null && <span style={{ color: '#8b9a9a' }}>{plural(conv.count, 'message')}</span>}
|
|
513
|
+
{conv.mine != null && conv.count != null && conv.mine === conv.count && <span style={chip('rgba(255,255,255,0.06)')}>Only you wrote</span>}
|
|
514
|
+
{conv.folder && conv.folder !== 'inbox' && <span style={chip('rgba(255,255,255,0.06)')}>{conv.folder}</span>}
|
|
515
|
+
{conv.kind === 'inmail' && <span style={chip('rgba(155,89,182,0.25)')}>InMail</span>}
|
|
516
|
+
{conv.kind === 'sponsored' && <span style={chip('rgba(255,255,255,0.08)')}>Sponsored</span>}
|
|
517
|
+
{conv.unread > 0 && <span style={chip('#3498DB', '#fff')}>{conv.unread} unread</span>}
|
|
518
|
+
</div>
|
|
519
|
+
{open === conv.id && <Thread key={`${conv.id}:${token}:${keep}`} id={conv.id} keep={keep} group={false} token={token} />}
|
|
520
|
+
</div>
|
|
521
|
+
))}
|
|
522
|
+
{c.groups.length > 0 && (
|
|
523
|
+
<div style={{ fontSize: 12.5, color: '#8b9a9a', marginTop: 4 }}>
|
|
524
|
+
Also in {plural(c.groups.length, 'group')}:{' '}
|
|
525
|
+
{c.groups.slice(0, 8).map((id, i) => {
|
|
526
|
+
const g = groups.find((x) => x.id === id);
|
|
527
|
+
return (
|
|
528
|
+
<span key={id}>
|
|
529
|
+
{i > 0 ? ', ' : ''}
|
|
530
|
+
<button onClick={() => onPickGroup(id)} style={{ background: 'none', border: 'none', color: '#3498DB', cursor: 'pointer', padding: 0, fontSize: 12.5 }}>{g ? groupLabel(g, nameOf) : 'a group'}</button>
|
|
531
|
+
</span>
|
|
532
|
+
);
|
|
533
|
+
})}
|
|
534
|
+
</div>
|
|
535
|
+
)}
|
|
536
|
+
|
|
537
|
+
<div style={h4}>Requests</div>
|
|
538
|
+
{c.invitesOut.length + c.invitesIn.length === 0 && !c.request && <div style={{ color: '#8b9a9a', fontSize: 12.5 }}>None on file.</div>}
|
|
539
|
+
{[...c.invitesOut, ...c.invitesIn].sort((a, b) => (b.t || 0) - (a.t || 0)).map((inv) => (
|
|
540
|
+
<div key={inv.id} style={{ fontSize: 12.5, marginBottom: 6 }}>
|
|
541
|
+
<span style={{ color: '#dfe8e8' }}>{inv.dir === 'out' ? 'You asked to connect' : 'They asked to connect'}</span>
|
|
542
|
+
<span style={{ color: '#8b9a9a' }}> · {day(inv.t) || 'no date'} · {c.connection ? 'accepted' : inv.dir === 'out' ? 'pending' : 'not accepted yet'}</span>
|
|
543
|
+
{keep && inviteNotes[inv.id] && <div style={{ marginTop: 3, padding: '6px 9px', borderRadius: 8, background: 'rgba(255,255,255,0.06)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{inviteNotes[inv.id]}</div>}
|
|
544
|
+
</div>
|
|
545
|
+
))}
|
|
546
|
+
{c.request && (
|
|
547
|
+
<div style={{ fontSize: 12.5 }}>
|
|
548
|
+
<span style={{ color: '#dfe8e8' }}>Request tracked in the app</span>
|
|
549
|
+
<span style={{ color: '#8b9a9a' }}> · {c.request.status}</span>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Your part: stage, follow-up, tags and notes, each saved as you change it. */
|
|
557
|
+
function Yours({ c, onChange }) {
|
|
558
|
+
const entry = c.crm || {};
|
|
559
|
+
const [notes, setNotes] = useState(entry.notes || '');
|
|
560
|
+
const [tag, setTag] = useState('');
|
|
561
|
+
const [state, setState] = useState(null);
|
|
562
|
+
const timer = useRef(null);
|
|
563
|
+
const suggested = suggestedStage(c);
|
|
564
|
+
|
|
565
|
+
async function put(set) {
|
|
566
|
+
setState('Saving…');
|
|
567
|
+
try {
|
|
568
|
+
await onChange(c.key, set);
|
|
569
|
+
setState('Saved');
|
|
570
|
+
} catch (e) {
|
|
571
|
+
setState(e.message);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// Notes save a moment after typing stops, and when you leave the box.
|
|
575
|
+
function typed(v) {
|
|
576
|
+
setNotes(v);
|
|
577
|
+
clearTimeout(timer.current);
|
|
578
|
+
timer.current = setTimeout(() => put({ notes: v }), 800);
|
|
579
|
+
}
|
|
580
|
+
function leave() {
|
|
581
|
+
clearTimeout(timer.current);
|
|
582
|
+
if (notes !== (entry.notes || '')) put({ notes });
|
|
583
|
+
}
|
|
584
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
585
|
+
const addDays = (n) => localDay(new Date(Date.now() + n * DAY));
|
|
586
|
+
const tags = entry.tags || [];
|
|
587
|
+
function addTag() {
|
|
588
|
+
const t = tag.trim();
|
|
589
|
+
if (!t) return;
|
|
590
|
+
setTag('');
|
|
591
|
+
put({ tags: [...tags, ...t.split(',')] });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<div style={{ marginTop: 12, padding: '10px 12px', borderRadius: 8, background: 'rgba(52,152,219,0.06)', border: '1px solid rgba(52,152,219,0.18)' }}>
|
|
596
|
+
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
597
|
+
<label style={{ fontSize: 11.5, color: '#8b9a9a', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
598
|
+
Stage
|
|
599
|
+
<select value={entry.stage || ''} onChange={(e) => put({ stage: e.target.value || null })} style={{ ...field, padding: '5px 8px' }}>
|
|
600
|
+
<option value="">Not set (looks like {stageLabel(suggested)})</option>
|
|
601
|
+
{STAGES.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
|
602
|
+
</select>
|
|
603
|
+
</label>
|
|
604
|
+
<label style={{ fontSize: 11.5, color: '#8b9a9a', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
605
|
+
Next follow-up
|
|
606
|
+
<input type="date" value={entry.followUp || ''} onChange={(e) => put({ followUp: e.target.value || null })} style={{ ...field, padding: '4px 8px', colorScheme: 'dark' }} />
|
|
607
|
+
</label>
|
|
608
|
+
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
609
|
+
<button onClick={() => put({ followUp: addDays(3) })} style={btn}>In 3 days</button>
|
|
610
|
+
<button onClick={() => put({ followUp: addDays(7) })} style={btn}>In a week</button>
|
|
611
|
+
{entry.followUp && <button onClick={() => put({ followUp: null })} style={btn}>Clear</button>}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
{entry.stage == null && suggested !== 'new' && (
|
|
615
|
+
<button onClick={() => put({ stage: suggested })} style={{ ...btn, marginTop: 8 }}>Set to {stageLabel(suggested)}</button>
|
|
616
|
+
)}
|
|
617
|
+
<div style={{ marginTop: 10, display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
618
|
+
{tags.map((t) => (
|
|
619
|
+
<span key={t} style={{ ...chip('rgba(255,255,255,0.08)'), fontSize: 11.5, display: 'inline-flex', gap: 4, alignItems: 'center' }}>
|
|
620
|
+
{t}
|
|
621
|
+
<button aria-label={`Remove ${t}`} onClick={() => put({ tags: tags.filter((x) => x !== t) })} style={{ background: 'none', border: 'none', color: '#8b9a9a', cursor: 'pointer', padding: 0, fontSize: 12 }}>×</button>
|
|
622
|
+
</span>
|
|
623
|
+
))}
|
|
624
|
+
<input
|
|
625
|
+
value={tag}
|
|
626
|
+
onChange={(e) => setTag(e.target.value)}
|
|
627
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }}
|
|
628
|
+
onBlur={addTag}
|
|
629
|
+
placeholder="Add a tag"
|
|
630
|
+
aria-label="Add a tag"
|
|
631
|
+
style={{ ...field, padding: '3px 8px', fontSize: 12, width: 120 }}
|
|
632
|
+
/>
|
|
633
|
+
</div>
|
|
634
|
+
<textarea
|
|
635
|
+
value={notes}
|
|
636
|
+
onChange={(e) => typed(e.target.value)}
|
|
637
|
+
onBlur={leave}
|
|
638
|
+
placeholder="Your notes on them: kept on this computer only"
|
|
639
|
+
aria-label="Notes"
|
|
640
|
+
rows={4}
|
|
641
|
+
style={{ ...field, width: '100%', marginTop: 10, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.45, boxSizing: 'border-box' }}
|
|
642
|
+
/>
|
|
643
|
+
<div style={{ fontSize: 11, color: state && state !== 'Saved' && state !== 'Saving…' ? '#ff9b9b' : '#778', marginTop: 4, minHeight: 14 }}>{state || ''}</div>
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function Group({ g, contacts, nameOf, keep, token, onPick }) {
|
|
649
|
+
const href = linkedin(g.threadUrl);
|
|
650
|
+
return (
|
|
651
|
+
<div style={{ fontSize: 13 }}>
|
|
652
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
653
|
+
<h3 style={{ margin: 0, fontSize: 17 }}>{groupLabel(g, nameOf)}</h3>
|
|
654
|
+
<span style={chip('rgba(255,255,255,0.08)')}>{g.unknown ? 'To someone unnamed' : 'Group'}</span>
|
|
655
|
+
{g.unread > 0 && <span style={chip('#3498DB', '#fff')}>{g.unread} unread</span>}
|
|
656
|
+
</div>
|
|
657
|
+
<div style={{ color: '#8b9a9a', marginTop: 4, fontSize: 12.5 }}>
|
|
658
|
+
{[g.last ? `Last active ${day(g.last)}` : null, g.count != null ? plural(g.count, 'message') : null].filter(Boolean).join(' · ')}
|
|
659
|
+
</div>
|
|
660
|
+
{href && <a href={href} target="_blank" rel="noreferrer" style={{ color: '#3498DB', textDecoration: 'none', fontSize: 12.5, display: 'inline-block', marginTop: 6 }}>Conversation on LinkedIn ↗</a>}
|
|
661
|
+
{g.people.length > 0 && (
|
|
662
|
+
<>
|
|
663
|
+
<div style={h4}>Who’s in it</div>
|
|
664
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
665
|
+
{g.people.map((k) => {
|
|
666
|
+
const p = contacts.get(k);
|
|
667
|
+
return (
|
|
668
|
+
<button key={k} onClick={() => onPick(k)} style={{ ...btn, display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
|
669
|
+
<Dot tier={p?.row?.tier} />{nameOf(k) || k.split('/').pop()}
|
|
670
|
+
{p && !p.connection && <span style={{ color: '#ffc58a', fontSize: 10.5 }}>not a connection</span>}
|
|
671
|
+
</button>
|
|
672
|
+
);
|
|
673
|
+
})}
|
|
674
|
+
</div>
|
|
675
|
+
</>
|
|
676
|
+
)}
|
|
677
|
+
<div style={h4}>Messages</div>
|
|
678
|
+
<div style={{ border: LINE, borderRadius: 6, overflow: 'hidden' }}>
|
|
679
|
+
<Thread key={`${g.id}:${token}:${keep}`} id={g.id} keep={keep} group token={token} />
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
);
|
|
683
|
+
}
|