mirror of
https://github.com/Such-Software/smirk-website.git
synced 2026-10-17 05:51:28 +02:00
fix(tips): show tips made by v0.2.x wallets on the tip page
The site now reads tips from the v0.3 backend, which does not hold tips created by v0.2.x wallets, so their links said "Tip not found". A 404 from the v0.3 backend now falls back to the legacy backend. Such tips are shown without a claim button, with a note that claiming needs the next wallet update, because a v0.3 wallet cannot claim them yet. Only the fields the page renders are kept from either response.
This commit is contained in:
@@ -119,7 +119,12 @@ export default function TipPageClient({ tipId }: { tipId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Tips made by v0.2.x wallets live on the legacy backend; a v0.3 wallet cannot
|
||||
// claim them yet, so the page shows them without a claim button.
|
||||
const isLegacyTip = tip?.source === 'legacy';
|
||||
|
||||
const isClaimable = tip?.status === 'pending' &&
|
||||
!isLegacyTip &&
|
||||
tip.is_public &&
|
||||
fragmentKey &&
|
||||
(tip.funding_confirmations ?? 0) >= (tip.confirmations_required ?? 0);
|
||||
@@ -255,6 +260,13 @@ export default function TipPageClient({ tipId }: { tipId: string }) {
|
||||
)}
|
||||
|
||||
{/* Claim button for public tips */}
|
||||
{isLegacyTip && tip.status === 'pending' && (
|
||||
<div className="rounded-lg border border-[#fbeb0a]/30 bg-[#fbeb0a]/5 p-4 text-sm text-gray-300">
|
||||
This tip was made with an earlier version of Smirk. Claiming it from this page
|
||||
needs the next Smirk Wallet update. The tip stays safe until then; keep this link.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isClaimable && !claimSuccess && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
|
||||
+11
-22
@@ -1,4 +1,7 @@
|
||||
import { parseUsernameResponse } from './username';
|
||||
import { lookupPublicTip, type PublicTipInfo } from './public-tip';
|
||||
|
||||
export type { PublicTipInfo } from './public-tip';
|
||||
|
||||
// API client for Smirk backend
|
||||
|
||||
@@ -6,6 +9,9 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
// Public counters (user count, stats) may live on a different backend than
|
||||
// sign-in. Unset means the same backend.
|
||||
const STATS_API_URL = process.env.NEXT_PUBLIC_STATS_API_URL || API_URL;
|
||||
// Tips created by v0.2.x wallets exist only on the legacy backend. The tip page
|
||||
// falls back to it when core has no such tip.
|
||||
const LEGACY_API_URL = process.env.NEXT_PUBLIC_LEGACY_API_URL || 'https://backend.smirk.cash';
|
||||
|
||||
export interface ChallengeResponse {
|
||||
challenge: string;
|
||||
@@ -189,29 +195,12 @@ export async function unlinkSocial(token: string, platform: string): Promise<voi
|
||||
// Public Tips
|
||||
// =============================================================================
|
||||
|
||||
export interface PublicTipInfo {
|
||||
id: string;
|
||||
asset: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
is_public: boolean;
|
||||
funding_confirmations: number;
|
||||
confirmations_required: number;
|
||||
}
|
||||
|
||||
export async function getPublicTipInfo(tipId: string): Promise<PublicTipInfo> {
|
||||
const res = await fetch(`${API_URL}/api/v1/tips/social/${tipId}/public`);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
throw new Error('Tip not found or not available');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Failed to get tip info');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return lookupPublicTip(tipId, {
|
||||
apiUrl: API_URL,
|
||||
legacyApiUrl: LEGACY_API_URL,
|
||||
fetchImpl: (url) => fetch(url),
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { lookupPublicTip, normalizePublicTip, TipNotFoundError } from './public-tip.ts';
|
||||
|
||||
const CORE = 'https://api.example';
|
||||
const LEGACY = 'https://legacy.example';
|
||||
|
||||
function tipBody(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'tip-1',
|
||||
asset: 'wow',
|
||||
amount: 5,
|
||||
status: 'pending',
|
||||
created_at: '2026-08-19T10:19:00Z',
|
||||
is_public: true,
|
||||
funding_confirmations: 4,
|
||||
confirmations_required: 4,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeFetch(responses: Record<string, { status: number; body?: unknown }>) {
|
||||
const calls: string[] = [];
|
||||
const impl = async (url: string) => {
|
||||
calls.push(url);
|
||||
const host = Object.keys(responses).find((h) => url.startsWith(h));
|
||||
const r = host ? responses[host] : { status: 500 };
|
||||
return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => r.body ?? {} };
|
||||
};
|
||||
return { impl, calls };
|
||||
}
|
||||
|
||||
test('a core tip is returned from core without touching legacy', async () => {
|
||||
const f = fakeFetch({ [CORE]: { status: 200, body: tipBody() } });
|
||||
const tip = await lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl });
|
||||
assert.equal(tip.source, 'core');
|
||||
assert.equal(f.calls.length, 1);
|
||||
});
|
||||
|
||||
test('a v0.2.x tip missing from core is found on legacy', async () => {
|
||||
const f = fakeFetch({ [CORE]: { status: 404 }, [LEGACY]: { status: 200, body: tipBody() } });
|
||||
const tip = await lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl });
|
||||
assert.equal(tip.source, 'legacy');
|
||||
assert.equal(tip.amount, 5);
|
||||
assert.deepEqual(f.calls, [`${CORE}/api/v1/tips/social/tip-1/public`, `${LEGACY}/api/v1/tips/social/tip-1/public`]);
|
||||
});
|
||||
|
||||
test('a tip on neither backend is not found', async () => {
|
||||
const f = fakeFetch({ [CORE]: { status: 404 }, [LEGACY]: { status: 404 } });
|
||||
await assert.rejects(
|
||||
lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }),
|
||||
TipNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test('a core server error is reported and does not fall back', async () => {
|
||||
const f = fakeFetch({ [CORE]: { status: 503, body: { error: 'busy' } }, [LEGACY]: { status: 200, body: tipBody() } });
|
||||
await assert.rejects(lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl }), /busy/);
|
||||
assert.equal(f.calls.length, 1);
|
||||
});
|
||||
|
||||
test('without a distinct legacy URL a core 404 is final', async () => {
|
||||
const f = fakeFetch({ [CORE]: { status: 404 } });
|
||||
await assert.rejects(lookupPublicTip('tip-1', { apiUrl: CORE, legacyApiUrl: CORE, fetchImpl: f.impl }), TipNotFoundError);
|
||||
assert.equal(f.calls.length, 1);
|
||||
});
|
||||
|
||||
test('legacy key material is never carried into the page model', () => {
|
||||
const tip = normalizePublicTip(tipBody({ encrypted_key: 'secret', tip_address: 'Wo...' }), 'legacy') as unknown as Record<string, unknown>;
|
||||
assert.equal('encrypted_key' in tip, false);
|
||||
assert.equal('tip_address' in tip, false);
|
||||
});
|
||||
|
||||
test('the tip id is URL-encoded', async () => {
|
||||
const f = fakeFetch({ [CORE]: { status: 200, body: tipBody() } });
|
||||
await lookupPublicTip('a/b', { apiUrl: CORE, legacyApiUrl: LEGACY, fetchImpl: f.impl });
|
||||
assert.equal(f.calls[0], `${CORE}/api/v1/tips/social/a%2Fb/public`);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Public tip lookup across the two backends.
|
||||
//
|
||||
// Tips created by v0.3 wallets live on the core backend (api.smirk.cash). Tips
|
||||
// created by v0.2.x wallets live only on the legacy backend (backend.smirk.cash).
|
||||
// The tip page must show both, so a 404 from core falls back to legacy.
|
||||
|
||||
export type TipSource = 'core' | 'legacy';
|
||||
|
||||
export interface PublicTipInfo {
|
||||
id: string;
|
||||
asset: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
is_public: boolean;
|
||||
funding_confirmations: number;
|
||||
confirmations_required: number;
|
||||
/** Which backend holds the tip. Only core tips can be claimed by a v0.3 wallet. */
|
||||
source: TipSource;
|
||||
}
|
||||
|
||||
export class TipNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Tip not found or not available');
|
||||
this.name = 'TipNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
type FetchLike = (url: string) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json(): Promise<unknown>;
|
||||
}>;
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
/** Keep only the fields the page renders; never carry key material along. */
|
||||
export function normalizePublicTip(raw: unknown, source: TipSource): PublicTipInfo {
|
||||
const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
||||
if (typeof r.id !== 'string' || typeof r.asset !== 'string' || typeof r.status !== 'string') {
|
||||
throw new Error('Failed to get tip info');
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
asset: r.asset,
|
||||
amount: toNumber(r.amount),
|
||||
status: r.status,
|
||||
created_at: typeof r.created_at === 'string' ? r.created_at : '',
|
||||
is_public: r.is_public === true,
|
||||
funding_confirmations: toNumber(r.funding_confirmations),
|
||||
confirmations_required: toNumber(r.confirmations_required),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
async function errorMessage(res: { json(): Promise<unknown> }): Promise<string> {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: unknown };
|
||||
return typeof data.error === 'string' && data.error ? data.error : 'Failed to get tip info';
|
||||
}
|
||||
|
||||
export async function lookupPublicTip(
|
||||
tipId: string,
|
||||
opts: { apiUrl: string; legacyApiUrl?: string; fetchImpl: FetchLike },
|
||||
): Promise<PublicTipInfo> {
|
||||
const path = `/api/v1/tips/social/${encodeURIComponent(tipId)}/public`;
|
||||
const core = await opts.fetchImpl(`${opts.apiUrl}${path}`);
|
||||
if (core.ok) return normalizePublicTip(await core.json(), 'core');
|
||||
if (core.status !== 404) throw new Error(await errorMessage(core));
|
||||
|
||||
const legacyUrl = opts.legacyApiUrl;
|
||||
if (!legacyUrl || legacyUrl === opts.apiUrl) throw new TipNotFoundError();
|
||||
const legacy = await opts.fetchImpl(`${legacyUrl}${path}`);
|
||||
if (legacy.ok) return normalizePublicTip(await legacy.json(), 'legacy');
|
||||
if (legacy.status === 404) throw new TipNotFoundError();
|
||||
throw new Error(await errorMessage(legacy));
|
||||
}
|
||||
Reference in New Issue
Block a user