Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
- name: Build bot bundle
run: npm run build --workspace @pointup/bot

- name: Build extension
run: npm run build --workspace @pointup/extension

infra:
name: Typecheck & synth infrastructure
runs-on: ubuntu-latest
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Track airline miles, hotel points, credit card rewards, and every other loyalty
- [docs/brand.md](./docs/brand.md) — brand kit: logo assets, color tokens, typography, voice
- [docs/migration-from-pointup.md](./docs/migration-from-pointup.md) — how the modernization was ported into `point_bot`, feature-parity checklist, and the Bedrock assistant
- [docs/bot.md](./docs/bot.md) — the PointBot chat surface: Slack/Discord commands, the `Notifier` port, digests, and deployment
- [docs/extension.md](./docs/extension.md) — the Chrome extension: capture balances from provider pages via `@pointup/api-client`

## Repository layout

Expand All @@ -39,7 +40,8 @@ Track airline miles, hotel points, credit card rewards, and every other loyalty
│ │ ├── src/components/ # branded UI components (logo, cards, forms)
│ │ └── public/brand/ # brand kit assets (SVG logomarks, lockup)
│ ├── worker/ # Background jobs: scheduled syncs + email + chat digests
│ └── bot/ # PointBot chat surface: Slack/Discord commands over the core
│ ├── bot/ # PointBot chat surface: Slack/Discord commands over the core
│ └── extension/ # Chrome (MV3) extension: capture balances from provider pages
├── packages/
│ ├── core/ # Domain + application + infrastructure (framework-free)
│ │ ├── src/domain/ # entities, provider catalog, repository ports, errors
Expand Down
22 changes: 22 additions & 0 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "@pointup/extension",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "PointUp Chrome extension: capture loyalty balances from provider pages you visit and record them as manual snapshots — sync without credentials",
"scripts": {
"build": "esbuild src/background.ts src/content.ts src/popup.ts --bundle --format=iife --target=chrome110 --outdir=dist && cp public/manifest.json public/popup.html dist/",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@pointup/api-client": "*",
"@pointup/core": "*"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"esbuild": "^0.25.0",
"typescript": "^5.9.0",
"vitest": "^3.0.0"
}
}
25 changes: 25 additions & 0 deletions apps/extension/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "PointUp balance capture",
"version": "1.0.0",
"description": "Capture loyalty balances from provider pages you visit and record them in PointUp — sync without sharing credentials.",
"permissions": ["storage"],
"host_permissions": ["https://*/*", "http://localhost/*"],
"background": { "service_worker": "background.js" },
"action": { "default_popup": "popup.html", "default_title": "PointUp" },
"content_scripts": [
{
"matches": [
"https://*.united.com/*",
"https://*.delta.com/*",
"https://*.aa.com/*",
"https://*.southwest.com/*",
"https://*.marriott.com/*",
"https://*.hyatt.com/*",
"https://*.hilton.com/*"
],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
91 changes: 91 additions & 0 deletions apps/extension/public/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PointUp</title>
<style>
body {
width: 320px;
margin: 0;
padding: 16px;
font-family: system-ui, sans-serif;
background: #0b1020;
color: #f4f6ff;
}
h1 {
font-size: 15px;
margin: 0 0 12px;
}
label {
display: block;
font-size: 12px;
color: #9aa5cb;
margin: 10px 0 4px;
}
input {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #232c4e;
background: #121a30;
color: #f4f6ff;
}
button {
margin-top: 12px;
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: #7c5cff;
color: #fff;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
#latest {
margin: 12px 0 4px;
padding: 8px;
border-radius: 6px;
background: #121a30;
font-size: 13px;
}
#status {
margin-top: 10px;
font-size: 12px;
color: #34d399;
min-height: 16px;
}
.row {
display: flex;
gap: 8px;
}
.row button {
flex: 1;
}
</style>
</head>
<body>
<h1>PointUp — capture balance</h1>

<div id="latest">Open a provider page to capture a balance.</div>

<div class="row">
<button id="record">Record balance</button>
</div>

<label for="baseUrl">API URL</label>
<input id="baseUrl" type="url" placeholder="https://app.example.com" />

<label for="token">Session token</label>
<input id="token" type="password" placeholder="Clerk session token" />

<button id="save">Save settings</button>

<div id="status"></div>

<script src="popup.js"></script>
</body>
</html>
85 changes: 85 additions & 0 deletions apps/extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { PointUpApiError, PointUpClient } from "@pointup/api-client";

import {
loadConfig,
loadLatestCapture,
saveLatestCapture,
} from "./config";
import type { ExtractedBalance } from "./extraction";
import type { ExtensionMessage, RecordResult } from "./messages";

function client(baseUrl: string, token: string): PointUpClient {
return new PointUpClient({
baseUrl,
headers: { Authorization: `Bearer ${token}` },
});
}

/** Record the capture against the user's matching account. */
async function record(capture: ExtractedBalance): Promise<RecordResult> {
const config = await loadConfig();
if (!config.baseUrl || !config.token) {
return { ok: false, message: "Set the API URL and token in the popup first." };
}

const api = client(config.baseUrl, config.token);
try {
const accounts = await api.listLoyaltyAccounts();
const account = accounts.find((a) => a.provider.id === capture.providerId);
if (!account) {
return {
ok: false,
message: `No linked ${capture.providerId} account — link it in PointUp first.`,
};
}
await api.recordManualBalance(account.id, { points: capture.points });
return {
ok: true,
message: `Recorded ${capture.points.toLocaleString("en-US")} for ${account.provider.displayName}.`,
};
} catch (error) {
if (error instanceof PointUpApiError) {
return { ok: false, message: `API error (${error.status}): ${error.message}` };
}
return { ok: false, message: "Could not reach the PointUp API." };
}
}

async function updateBadge(capture: ExtractedBalance | null): Promise<void> {
await chrome.action.setBadgeText({ text: capture ? "1" : "" });
if (capture) {
await chrome.action.setBadgeBackgroundColor({ color: "#7C5CFF" });
}
}

chrome.runtime.onMessage.addListener(
(message: ExtensionMessage, _sender, sendResponse) => {
if (message.type === "capture") {
void saveLatestCapture(message.capture).then(() =>
updateBadge(message.capture),
);
return; // no async response needed
}
if (message.type === "getLatest") {
void loadLatestCapture().then((capture) => sendResponse(capture));
return true; // async response
}
if (message.type === "record") {
void loadLatestCapture()
.then((capture) =>
capture
? record(capture)
: Promise.resolve({
ok: false,
message: "Nothing captured yet — open a provider page.",
}),
)
.then((result) => {
if (result.ok) void saveLatestCapture(null).then(() => updateBadge(null));
sendResponse(result);
});
return true; // async response
}
return undefined;
},
);
33 changes: 33 additions & 0 deletions apps/extension/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ExtractedBalance } from "./extraction";

/** User settings, stored in chrome.storage.local. */
export interface ExtensionConfig {
/** PointUp API base URL, e.g. https://app.example.com */
readonly baseUrl: string;
/** Clerk session token (bearer). See docs/multi-surface.md. */
readonly token: string;
}

export async function loadConfig(): Promise<ExtensionConfig> {
const stored = await chrome.storage.local.get(["baseUrl", "token"]);
return {
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : "",
token: typeof stored.token === "string" ? stored.token : "",
};
}

export async function saveConfig(config: ExtensionConfig): Promise<void> {
await chrome.storage.local.set(config);
}

/** The most recently captured balance (persisted so the popup can show it). */
export async function saveLatestCapture(
capture: ExtractedBalance | null,
): Promise<void> {
await chrome.storage.local.set({ latestCapture: capture });
}

export async function loadLatestCapture(): Promise<ExtractedBalance | null> {
const stored = await chrome.storage.local.get("latestCapture");
return (stored.latestCapture as ExtractedBalance | null) ?? null;
}
27 changes: 27 additions & 0 deletions apps/extension/src/content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { extractBalance } from "./extraction";
import type { CaptureMessage } from "./messages";

/**
* Runs on known provider pages. Reads the visible page text, extracts the
* loyalty balance, and hands it to the background worker. No credentials are
* ever read — only the balance number the page already shows the signed-in user.
*/
function capture(): void {
const capture = extractBalance({
url: location.href,
text: document.body?.innerText ?? "",
});
if (!capture) return;

const message: CaptureMessage = { type: "capture", capture };
void chrome.runtime.sendMessage(message);
}

// Provider dashboards render balances after hydration; retry a few times.
capture();
let attempts = 0;
const timer = setInterval(() => {
attempts += 1;
capture();
if (attempts >= 5) clearInterval(timer);
}, 2000);
Loading
Loading