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
39 changes: 39 additions & 0 deletions apps/desktop/src/main/agent-cli/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,43 @@ export class PairuxClient {
method: 'DELETE',
});
}

/** Redeem a credit code (adds days of a paid plan). */
redeem(code: string) {
return this.request<{
plan: string;
days: number;
effectivePlan: string;
planExpiresAt: string | null;
message: string;
}>('/api/credits/redeem', { method: 'POST', body: JSON.stringify({ code }) }, true);
}

invites() {
return this.request<InvitesOverview>('/api/invites', {}, true);
}

createInvite(note: string) {
return this.request<{ id: string; code: string; url: string; expiresAt: number | null }>(
'/api/invites',
{ method: 'POST', body: JSON.stringify({ note }) },
true
);
}
}

export interface InvitesOverview {
quota: { total: number; used: number; left: number };
invites: {
id: string;
status: string;
note: string;
usedByName?: string | null;
bonus?: string;
expiresAt: number | null;
}[];
canInvite: boolean;
unlocksAt: string | null;
bonusDays: number;
refLink: string;
}
96 changes: 96 additions & 0 deletions apps/desktop/src/main/agent-cli/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,3 +442,99 @@
expect(out[1]).not.toBe(out[0]);
});
});

describe('pairux redeem / invites', () => {
async function signIn(d: Deps) {
await (
await import('./config')
).saveConfig(
{
tokens: {
accessToken: 'pux_at_x',
refreshToken: 'pux_rt_x',
expiresAt: Date.now() + 3_600_000,
},
},
d.configPath
);
}

it('redeems a code with the access token', async () => {
const d = deps((call) =>
call.path === '/api/credits/redeem'
? {
status: 200,
body: {
data: {
plan: 'pro',
days: 30,
effectivePlan: 'pro',
planExpiresAt: '2026-11-25T00:00:00.000Z',
message: 'Code redeemed: 30 days of PairUX Pro.',
},
},
}
: { status: 404, body: { error: 'nope' } }
);
await signIn(d);
expect(await run(['redeem', 'CR-AAAA-BBBB-CCCC-DDDD'], d)).toBe(0);
expect(calls[0].auth).toBe('Bearer pux_at_x');
expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ code: 'CR-AAAA-BBBB-CCCC-DDDD' });
expect(out).toEqual([
'Code redeemed: 30 days of PairUX Pro.',
'Plan: pro, active until 2026-11-25.',
]);
});

it('asks to sign in before redeeming', async () => {
const d = deps(() => ({ status: 500, body: {} }));
expect(await run(['redeem', 'CR-1'], d)).not.toBe(0);
expect(calls).toHaveLength(0);
});

it('creates an invite and lists invites', async () => {
const d = deps((call) =>
call.method === 'POST'
? {
status: 201,
body: {
data: {
id: 'i1',
code: 'INV-1',
url: 'https://pairux.test/i/INV-1',
expiresAt: null,
},
},
}
: {
status: 200,
body: {
data: {
quota: { total: 3, used: 1, left: 2 },
invites: [
{
id: 'i1',
status: 'used',
note: 'Ada',
usedByName: '@ada',
bonus: 'granted',
expiresAt: null,
},
],
canInvite: true,
unlocksAt: null,
bonusDays: 7,
refLink: 'https://pairux.test/?ref=ABC',
},
},
}
);
await signIn(d);
expect(await run(['invites', 'create', '--note', 'Ada'], d)).toBe(0);
expect(out[0]).toBe('Invite link (shown once): https://pairux.test/i/INV-1');
expect(await run(['invites'], d)).toBe(0);
expect(out).toContain('2 of 3 invites left.');
expect(out).toContain(' used by @ada (bonus granted) Ada');
expect(out.at(-1)).toBe('Referral link: https://pairux.test/?ref=ABC');
});
});
47 changes: 47 additions & 0 deletions apps/desktop/src/main/agent-cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ Usage:
pairux leave Leave the session
pairux room Print a link to a fresh anonymous room: end-to-end
encrypted voice/video, no account needed to join
pairux redeem <CODE> Redeem a credit code (adds days of a paid plan)
pairux invites [--json] Your invites left, invites sent and referral link
pairux invites create [--note <s>] Create an invite link (3 per account)

Environment:
PAIRUX_API_URL default https://pairux.com
Expand Down Expand Up @@ -203,6 +206,50 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
return 0;
}

case 'redeem': {
const code = rest.join(' ').trim();
if (!code) throw new UsageError('Usage: pairux redeem <CODE>');
if (!config.tokens) throw new UsageError('Sign in first: `pairux login`.');
const r = await client.redeem(code);
deps.out(r.message);
if (r.planExpiresAt) {
deps.out(`Plan: ${r.effectivePlan}, active until ${r.planExpiresAt.slice(0, 10)}.`);
}
return 0;
}

case 'invites': {
if (!config.tokens) throw new UsageError('Sign in first: `pairux login`.');
const { values, positionals } = parseArgs({
args: rest,
allowPositionals: true,
options: { note: { type: 'string' }, json: { type: 'boolean', default: false } },
});
if (positionals[0] === 'create') {
const inv = await client.createInvite(values.note ?? '');
deps.out(values.json ? JSON.stringify(inv) : `Invite link (shown once): ${inv.url}`);
return 0;
}
if (positionals[0]) throw new UsageError('Usage: pairux invites [create [--note <text>]]');
const o = await client.invites();
if (values.json) {
deps.out(JSON.stringify(o));
return 0;
}
deps.out(`${String(o.quota.left)} of ${String(o.quota.total)} invites left.`);
deps.out(
`Someone new who joins on your invite and confirms their email gets ${String(o.bonusDays)} days of Pro, and so do you.`
);
if (!o.canInvite && o.unlocksAt) deps.out(`Invites unlock on ${o.unlocksAt.slice(0, 10)}.`);
for (const i of o.invites) {
const who = i.usedByName ? ` by ${i.usedByName}` : '';
const bonus = i.bonus ? ` (bonus ${i.bonus})` : '';
deps.out(` ${i.status}${who}${bonus}${i.note ? ` ${i.note}` : ''}`);
}
deps.out(`Referral link: ${o.refLink}`);
return 0;
}

case 'whoami': {
if (!config.tokens) {
deps.out(
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/agent-cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* `pairux login|logout|whoami|join|listen|say|who|status|leave|room`
* `pairux login|logout|whoami|join|listen|say|who|status|leave|room|redeem|invites`
*
* The installed `pairux` launcher passes any argument it does not handle
* itself (update, uninstall, --help, --version) straight to the app, the same
Expand All @@ -22,6 +22,8 @@ export const AGENT_COMMANDS = new Set([
'status',
'leave',
'room',
'redeem',
'invites',
]);

/**
Expand Down
4 changes: 3 additions & 1 deletion apps/installer/scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ if /I "%~1"=="update" goto :update
if /I "%~1"=="upgrade" goto :update
if /I "%~1"=="uninstall" goto :uninstall
if /I "%~1"=="remove" goto :uninstall
for %%C in (login logout whoami join listen say who status leave) do if /I "%~1"=="%%C" goto :agent
for %%C in (login logout whoami join listen say who status leave room redeem invites) do if /I "%~1"=="%%C" goto :agent
goto :run

:help
Expand All @@ -184,6 +184,8 @@ echo join CODE Join a session as an agent (--name, --client)
echo listen [--json] Follow chat and who is here; keeps the agent present
echo say MESSAGE Post in the session chat
echo who ^| status ^| leave Roster, this agent, leave the session
echo redeem CODE Redeem a credit code (adds days of a paid plan)
echo invites [create] Your invites and referral link
exit /b 0

:version
Expand Down
4 changes: 4 additions & 0 deletions apps/installer/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,8 @@ case "\${1-}" in
echo " listen [--json] Follow chat and who is here; keeps the agent present"
echo " say <message> Post in the session chat"
echo " who | status | leave Roster, this agent, leave the session"
echo " redeem <CODE> Redeem a credit code (adds days of a paid plan)"
echo " invites [create] Your invites and referral link"
exit 0
;;
-v|--version)
Expand Down Expand Up @@ -989,6 +991,8 @@ case "\${1-}" in
echo " listen [--json] Follow chat and who is here; keeps the agent present"
echo " say <message> Post in the session chat"
echo " who | status | leave Roster, this agent, leave the session"
echo " redeem <CODE> Redeem a credit code (adds days of a paid plan)"
echo " invites [create] Your invites and referral link"
exit 0
;;
-v|--version)
Expand Down
55 changes: 55 additions & 0 deletions apps/web/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -2261,5 +2261,60 @@
"paid": "شكرًا! تُفعَّل خطة {plan} فور تأكيد الدفع، عادةً خلال دقائق.",
"toDashboard": "اذهب إلى لوحة التحكم"
}
},
"credits": {
"billingTitle": "الخطة والفوترة",
"planName": "خطة {plan}",
"current": "الحالية",
"activeUntil": "نشطة حتى {date}",
"seePlans": "عرض الخطط",
"haveACode": "لديك رمز؟",
"redeem": "استرداد",
"redeeming": "جارٍ الاسترداد...",
"redeemFailed": "تعذر استرداد الرمز. حاول مرة أخرى.",
"inviteLink": "ادعُ شخصًا (ستحصلان معًا على أيام Pro)",
"invites": {
"title": "الدعوات",
"subtitle": "الدعوات محدودة: لكل حساب ثلاث دعوات. أرسل واحدة لمن تريده معك على PairUX.",
"loading": "جارٍ التحميل...",
"loadFailed": "تعذر تحميل الدعوات.",
"createFailed": "تعذر إنشاء الدعوة.",
"revokeFailed": "تعذر سحب الدعوة.",
"left": "تبقّى {left} من {total} دعوات",
"explain": "عندما ينضم شخص جديد عبر دعوتك ويؤكد بريده الإلكتروني، تحصلان معًا على {days} يومًا من PairUX Pro.",
"notePlaceholder": "لمن هذه الدعوة؟ (أنت فقط ترى هذا)",
"create": "إنشاء دعوة",
"noneLeft": "لقد استخدمت جميع دعواتك.",
"unlocks": "تُفتح الدعوات في {date}، بعد أسبوع من انضمامك.",
"locked": "الدعوات غير متاحة لهذا الحساب بعد.",
"shownOnce": "رابط دعوتك (يظهر مرة واحدة، انسخه الآن):",
"copy": "نسخ",
"copied": "تم النسخ",
"sent": "الدعوات التي أرسلتها",
"noNote": "دعوة",
"expires": "تنتهي في {date}",
"withdraw": "سحب",
"status": {
"open": "مفتوحة",
"used": "انضم",
"expired": "منتهية",
"revoked": "مسحوبة"
},
"bonus": {
"granted": "{days} يومًا من Pro لكل منكما",
"pending": "المكافأة بانتظار تأكيد بريده الإلكتروني",
"denied": "لا مكافأة"
},
"refTitle": "رابط الإحالة الخاص بك",
"refExplain": "رابط مفتوح بلا حد. التسجيلات التي تتم عبره تُنسب إليك."
},
"landing": {
"title": "أنت مدعو إلى PairUX",
"body": "يريدك شخص ما على PairUX: مشاركة الشاشة مع التحكم عن بُعد، والاجتماعات، والبث المباشر. انضم عبر هذه الدعوة وستحصلان معًا على أيام من PairUX Pro.",
"accept": "قبول الدعوة",
"signup": "أنشئ حسابك المجاني",
"login": "لدي حساب بالفعل",
"failed": "تعذر قبول الدعوة."
}
}
}
55 changes: 55 additions & 0 deletions apps/web/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2261,5 +2261,60 @@
"paid": "Danke! Dein {plan}-Plan wird aktiv, sobald die Zahlung bestätigt ist, meist innerhalb weniger Minuten.",
"toDashboard": "Zum Dashboard"
}
},
"credits": {
"billingTitle": "Tarif und Abrechnung",
"planName": "Tarif {plan}",
"current": "Aktuell",
"activeUntil": "Aktiv bis {date}",
"seePlans": "Tarife ansehen",
"haveACode": "Du hast einen Code?",
"redeem": "Einlösen",
"redeeming": "Wird eingelöst...",
"redeemFailed": "Der Code konnte nicht eingelöst werden. Versuche es erneut.",
"inviteLink": "Lade jemanden ein (ihr beide bekommt Pro-Tage)",
"invites": {
"title": "Einladungen",
"subtitle": "Einladungen sind knapp: Jedes Konto hat drei. Schicke eine an jemanden, den du bei PairUX dabeihaben möchtest.",
"loading": "Wird geladen...",
"loadFailed": "Einladungen konnten nicht geladen werden.",
"createFailed": "Die Einladung konnte nicht erstellt werden.",
"revokeFailed": "Die Einladung konnte nicht zurückgezogen werden.",
"left": "Noch {left} von {total} Einladungen übrig",
"explain": "Wenn jemand Neues über deine Einladung beitritt und die E-Mail-Adresse bestätigt, bekommt ihr beide {days} Tage PairUX Pro.",
"notePlaceholder": "Für wen ist sie? (nur du siehst das)",
"create": "Einladung erstellen",
"noneLeft": "Du hast alle deine Einladungen verbraucht.",
"unlocks": "Einladungen werden am {date} freigeschaltet, eine Woche nach deiner Anmeldung.",
"locked": "Einladungen sind für dieses Konto noch nicht verfügbar.",
"shownOnce": "Dein Einladungslink (wird nur einmal angezeigt, kopiere ihn jetzt):",
"copy": "Kopieren",
"copied": "Kopiert",
"sent": "Von dir gesendete Einladungen",
"noNote": "Einladung",
"expires": "läuft am {date} ab",
"withdraw": "Zurückziehen",
"status": {
"open": "Offen",
"used": "Beigetreten",
"expired": "Abgelaufen",
"revoked": "Zurückgezogen"
},
"bonus": {
"granted": "je {days} Tage Pro",
"pending": "Bonus wartet auf die Bestätigung der E-Mail-Adresse",
"denied": "kein Bonus"
},
"refTitle": "Dein Empfehlungslink",
"refExplain": "Ein offener Link ohne Limit. Anmeldungen darüber werden dir gutgeschrieben."
},
"landing": {
"title": "Du bist zu PairUX eingeladen",
"body": "Jemand möchte dich bei PairUX dabeihaben: Bildschirmfreigabe mit Fernsteuerung, Meetings und Livestreams. Tritt mit dieser Einladung bei und ihr bekommt beide Tage PairUX Pro.",
"accept": "Einladung annehmen",
"signup": "Kostenloses Konto erstellen",
"login": "Ich habe schon ein Konto",
"failed": "Die Einladung konnte nicht angenommen werden."
}
}
}
Loading
Loading