Compare commits
2
Commits
0969629122
...
75a0d7f637
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a0d7f637 | ||
|
|
3858b97104 |
@@ -4,3 +4,9 @@
|
||||
# REACT_APP_BACKEND_WS_API_BASE_URL=wss://beta.chatbackend.aimloperations.com/ws/chat_again/
|
||||
REACT_APP_BACKEND_REST_API_BASE_URL=https://chatbackend.aimloperations.com/api/
|
||||
REACT_APP_BACKEND_WS_API_BASE_URL=wss://chatbackend.aimloperations.com/ws/chat_again/
|
||||
|
||||
# RevenueCat public SDK keys (native IAP). Leave empty until store apps are wired.
|
||||
REACT_APP_REVENUECAT_APPLE_API_KEY=
|
||||
REACT_APP_REVENUECAT_GOOGLE_API_KEY=
|
||||
# Optional: pin a specific offering identifier (defaults to current offering).
|
||||
# REACT_APP_REVENUECAT_OFFERING_ID=
|
||||
|
||||
@@ -27,6 +27,10 @@ Page views: always via `Tracker` / `tracker.js` on prod + beta.
|
||||
| Account Delete Started | `ACCOUNT_DELETE_STARTED` | DeleteAccountSection | — |
|
||||
| Account Delete Success | `ACCOUNT_DELETE_SUCCESS` | DeleteAccountSection | — |
|
||||
| Account Delete Failed | `ACCOUNT_DELETE_FAILED` | DeleteAccountSection | — |
|
||||
| Message Copied | `MESSAGE_COPIED` | MessageActions | `{ role }` |
|
||||
| Message Rated | `MESSAGE_RATED` | MessageActions | `{ rating: 'up' \| 'down' \| 'cleared' }` |
|
||||
| Message Rating Reason | `MESSAGE_RATING_REASON` | MessageActions | `{ reason, hasComment }` |
|
||||
| Message Exported | `MESSAGE_EXPORTED` | MessageActions, AsyncDashboard2 | `{ format, scope }` |
|
||||
|
||||
## Identify
|
||||
|
||||
|
||||
@@ -40,6 +40,20 @@ npm run android:sync
|
||||
|
||||
Default `.env.mobile` matches production (`chatbackend.aimloperations.com`). Point it at beta to flip the shell without touching web deploys. Optional gitignored override: `.env.mobile.local`.
|
||||
|
||||
### RevenueCat IAP (#100)
|
||||
|
||||
Native billing uses `@revenuecat/purchases-capacitor` (Capacitor 7 → package **11.x**). Web keeps Stripe Checkout / Customer Portal.
|
||||
|
||||
Set public SDK keys in `.env.mobile` (empty placeholders OK until store apps ship):
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `REACT_APP_REVENUECAT_APPLE_API_KEY` | iOS public SDK key |
|
||||
| `REACT_APP_REVENUECAT_GOOGLE_API_KEY` | Android public SDK key |
|
||||
| `REACT_APP_REVENUECAT_OFFERING_ID` | Optional offering pin (else current) |
|
||||
|
||||
After install / key changes: `npm run build:mobile` (runs `cap sync`). More detail: [`MONETIZATION.md`](MONETIZATION.md).
|
||||
|
||||
## Versioning
|
||||
|
||||
In `android/app/build.gradle`:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Monetization (web Stripe + native RevenueCat)
|
||||
|
||||
## Channels
|
||||
|
||||
| Runtime | Checkout / manage | Ledger |
|
||||
|---------|-------------------|--------|
|
||||
| Web (`!isNativePlatform`) | Stripe Checkout + Customer Portal via `/finance/...` | Stripe webhooks |
|
||||
| Native Capacitor | RevenueCat IAP (`@revenuecat/purchases-capacitor`) | RevenueCat webhooks → same invoice/payment tables |
|
||||
|
||||
FE displays whatever `/finance/invoices/` (and subscription) returns after refresh. Provider badge uses `provider` + `revenuecat_store`.
|
||||
|
||||
## App user id
|
||||
|
||||
RevenueCat `appUserID` = Django user pk as string.
|
||||
|
||||
1. Prefer `id` from `/user/get/` (serializer returns all model fields).
|
||||
2. Else JWT `user_id` claim (`SIMPLE_JWT.USER_ID_CLAIM`).
|
||||
|
||||
Backend webhook resolver accepts numeric pk (or email fallback).
|
||||
|
||||
## Mobile env keys
|
||||
|
||||
In `.env.mobile`:
|
||||
|
||||
- `REACT_APP_REVENUECAT_APPLE_API_KEY`
|
||||
- `REACT_APP_REVENUECAT_GOOGLE_API_KEY`
|
||||
- `REACT_APP_REVENUECAT_OFFERING_ID` (optional)
|
||||
|
||||
Package identifiers in the RC dashboard should include plan slugs (e.g. `founders`) so `purchasePlan(planSlug)` can match packages/products. Optional backend `REVENUECAT_PRODUCT_PLAN_MAP` maps product id → plan slug.
|
||||
|
||||
## Auth hooks
|
||||
|
||||
- `Purchases.configure` once on native (first purchase / logIn).
|
||||
- `logIn(appUserID)` after SignIn / SignUp / AuthCallback / AccountContext load.
|
||||
- `logOut` on Header2 sign-out and Delete account.
|
||||
|
||||
## Related
|
||||
|
||||
- Issue #100 (FE) + companion backend RevenueCat webhook PR
|
||||
- [`ANDROID.md`](ANDROID.md) / [`IOS.md`](IOS.md) for store builds
|
||||
Generated
+574
-30
File diff suppressed because it is too large
Load Diff
@@ -16,21 +16,27 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^5.16.11",
|
||||
"@mui/material": "^5.16.11",
|
||||
"@revenuecat/purchases-capacitor": "^11.3.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdfmake": "^0.3.3",
|
||||
"axios": "^1.13.2",
|
||||
"babel-loader": "^9.2.1",
|
||||
"bootstrap": "^5.3.3",
|
||||
"brace-expansion": "file:vendor/brace-expansion-compat",
|
||||
"chroma-js": "^3.1.2",
|
||||
"docx": "^9.7.1",
|
||||
"formik": "^2.4.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-to-jsx": "^7.7.2",
|
||||
"mini.css": "^3.0.1",
|
||||
"papaparse": "^5.5.4",
|
||||
"pdfmake": "^0.3.11",
|
||||
"react-bootstrap": "^2.10.6",
|
||||
"react-code-blocks": "^0.1.6",
|
||||
"react-github-btn": "^1.4.0",
|
||||
@@ -43,6 +49,7 @@
|
||||
"web-vitals": "^4.2.4",
|
||||
"webpack": "^5.97.1",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"xlsx": "^0.18.5",
|
||||
"yup": "^1.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -80,6 +87,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@types/bootstrap": "~5.2.10",
|
||||
"@types/lodash": "~4.17.13",
|
||||
"@types/react": "^18.3.16",
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AnalyticsConsentProvider } from './llm-fe/contexts/AnalyticsConsentCont
|
||||
import AnalyticsConsentBanner from './llm-fe/components/AnalyticsConsentBanner/AnalyticsConsentBanner';
|
||||
import AnalyticsSession from './llm-fe/components/AnalyticsSession/AnalyticsSession';
|
||||
import AppErrorBoundary from './llm-fe/components/AppErrorBoundary/AppErrorBoundary';
|
||||
import ToastHost from './llm-fe/components/ToastHost/ToastHost';
|
||||
|
||||
const ProtectedRoutes = () => {
|
||||
const { authenticated, loading } = useContext(AuthContext);
|
||||
@@ -48,6 +49,7 @@ class App extends Component {
|
||||
<AnalyticsConsentProvider>
|
||||
<Tracker />
|
||||
<AnalyticsSession />
|
||||
<ToastHost />
|
||||
<AnalyticsConsentBanner />
|
||||
<div className='site'>
|
||||
<main>
|
||||
|
||||
@@ -8,6 +8,9 @@ const mockGet = jest.fn();
|
||||
const mockPost = jest.fn();
|
||||
const assignMock = jest.fn();
|
||||
const mockTrackEvent = jest.fn();
|
||||
const mockPurchasePlan = jest.fn();
|
||||
const mockRestorePurchases = jest.fn();
|
||||
const mockIsNativePlatform = jest.fn(() => false);
|
||||
|
||||
jest.mock('../../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
@@ -27,6 +30,17 @@ jest.mock('../../utils/analytics', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../platform/nativePlatform', () => ({
|
||||
isNativePlatform: () => mockIsNativePlatform(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
purchasePlan: (...args: unknown[]) => mockPurchasePlan(...args),
|
||||
restorePurchases: (...args: unknown[]) => mockRestorePurchases(...args),
|
||||
isPurchaseCancelledError: (error: { userCancelled?: boolean }) =>
|
||||
Boolean(error?.userCancelled),
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
main: '#4a90e2',
|
||||
darkMode: true,
|
||||
@@ -174,6 +188,9 @@ describe('BillingSection', () => {
|
||||
mockPost.mockReset();
|
||||
assignMock.mockReset();
|
||||
mockTrackEvent.mockReset();
|
||||
mockPurchasePlan.mockReset();
|
||||
mockRestorePurchases.mockReset();
|
||||
mockIsNativePlatform.mockReturnValue(false);
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -345,4 +362,49 @@ describe('BillingSection', () => {
|
||||
expect(await screen.findByText('No Stripe customer found')).toBeInTheDocument();
|
||||
expect(assignMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('on native uses RevenueCat purchase and shows Restore', async () => {
|
||||
mockIsNativePlatform.mockReturnValue(true);
|
||||
mockFinanceGets();
|
||||
mockPurchasePlan.mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderBilling();
|
||||
|
||||
expect(await screen.findByTestId('restore-purchases')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /Complete payment/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPurchasePlan).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockPost).not.toHaveBeenCalledWith(
|
||||
'/finance/checkout/',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('shows provider badge for RevenueCat invoices', async () => {
|
||||
mockFinanceGets({
|
||||
invoices: [
|
||||
{
|
||||
...paidInvoice,
|
||||
provider: 'revenuecat',
|
||||
stripe_subscription_id: null,
|
||||
revenuecat_store: 'PLAY_STORE',
|
||||
hosted_invoice_url: '',
|
||||
description: 'Store IAP (PLAY_STORE) — founders — INITIAL_PURCHASE',
|
||||
},
|
||||
],
|
||||
subscription: {
|
||||
...foundersSubscription,
|
||||
source: 'revenuecat',
|
||||
stripe_subscription_id: '',
|
||||
},
|
||||
});
|
||||
|
||||
renderBilling();
|
||||
|
||||
expect(await screen.findByText('Play Store')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Store IAP/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,12 +12,21 @@ import {
|
||||
formatTokenCount,
|
||||
higherSelectablePlans,
|
||||
humanizeStatus,
|
||||
invoiceProviderLabel,
|
||||
isComplimentarySubscription,
|
||||
isStoreSubscription,
|
||||
isStripeSubscription,
|
||||
otherSelectablePlans,
|
||||
pickPrimaryInvoice,
|
||||
SubscriptionMe,
|
||||
SubscriptionPlanInfo,
|
||||
} from '../../utils/finance';
|
||||
import { isNativePlatform } from '../../platform/nativePlatform';
|
||||
import {
|
||||
isPurchaseCancelledError,
|
||||
purchasePlan,
|
||||
restorePurchases,
|
||||
} from '../../utils/revenueCat';
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: ${({ theme }) => theme.colors.cardBackground};
|
||||
@@ -233,6 +242,14 @@ const InvoiceLink = styled.a`
|
||||
}
|
||||
`;
|
||||
|
||||
const ProviderBadge = styled.span`
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { detail?: string } };
|
||||
@@ -241,9 +258,19 @@ function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
return axiosError.response?.data?.detail || axiosError.message || fallback;
|
||||
}
|
||||
|
||||
function storeManageLabel(): string {
|
||||
if (typeof window === 'undefined') return 'App Store / Play Store';
|
||||
const Cap = window.Capacitor as { getPlatform?: () => string } | undefined;
|
||||
const platform = Cap?.getPlatform?.();
|
||||
if (platform === 'ios') return 'App Store';
|
||||
if (platform === 'android') return 'Play Store';
|
||||
return 'App Store / Play Store';
|
||||
}
|
||||
|
||||
type PortalIntent = 'manage' | 'upgrade' | 'change' | 'cancel';
|
||||
|
||||
const BillingSection = (): JSX.Element => {
|
||||
const native = isNativePlatform();
|
||||
const [invoices, setInvoices] = useState<FinanceInvoice[]>([]);
|
||||
const [payments, setPayments] = useState<FinancePayment[]>([]);
|
||||
const [subscription, setSubscription] = useState<SubscriptionMe | null>(null);
|
||||
@@ -251,8 +278,10 @@ const BillingSection = (): JSX.Element => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [listError, setListError] = useState('');
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [actionNotice, setActionNotice] = useState('');
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [checkoutLoadingSlug, setCheckoutLoadingSlug] = useState<string | null>(null);
|
||||
const [restoreLoading, setRestoreLoading] = useState(false);
|
||||
const [showPlanPicker, setShowPlanPicker] = useState(false);
|
||||
const [planPickerMode, setPlanPickerMode] = useState<'upgrade' | 'change'>('change');
|
||||
const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false);
|
||||
@@ -293,6 +322,16 @@ const BillingSection = (): JSX.Element => {
|
||||
() => isComplimentarySubscription(subscription, hasPortalAccess),
|
||||
[subscription, hasPortalAccess]
|
||||
);
|
||||
const storeSub = useMemo(
|
||||
() => isStoreSubscription(subscription?.source),
|
||||
[subscription?.source]
|
||||
);
|
||||
const stripeSub = useMemo(
|
||||
() => isStripeSubscription(subscription?.source) || hasPortalAccess,
|
||||
[subscription?.source, hasPortalAccess]
|
||||
);
|
||||
const showStripeManage = !complimentary && stripeSub && hasPortalAccess;
|
||||
const showStoreManage = !complimentary && (storeSub || (native && !showStripeManage && Boolean(subscription?.plan) && !subscription?.needs_checkout));
|
||||
const upgradePlans = useMemo(
|
||||
() => higherSelectablePlans(plans, subscription?.plan),
|
||||
[plans, subscription?.plan]
|
||||
@@ -306,6 +345,7 @@ const BillingSection = (): JSX.Element => {
|
||||
subscription?.current_period_end || primaryInvoice?.period_end || null;
|
||||
return formatBillingDate(end);
|
||||
}, [subscription?.current_period_end, primaryInvoice?.period_end]);
|
||||
const storeLabel = useMemo(() => storeManageLabel(), []);
|
||||
|
||||
const historyRows = useMemo(() => {
|
||||
if (invoices.length) {
|
||||
@@ -313,6 +353,7 @@ const BillingSection = (): JSX.Element => {
|
||||
key: `invoice-${invoice.id}`,
|
||||
date: invoice.created,
|
||||
description: invoice.description || 'Invoice',
|
||||
provider: invoiceProviderLabel(invoice),
|
||||
amount: formatMoneyCents(
|
||||
invoice.amount_paid || invoice.amount_due,
|
||||
invoice.currency
|
||||
@@ -325,6 +366,12 @@ const BillingSection = (): JSX.Element => {
|
||||
key: `payment-${payment.id}`,
|
||||
date: payment.paid_at || payment.created,
|
||||
description: 'Payment',
|
||||
provider:
|
||||
(payment.provider || '').toLowerCase() === 'revenuecat'
|
||||
? 'Store'
|
||||
: (payment.provider || '').toLowerCase() === 'stripe'
|
||||
? 'Stripe'
|
||||
: payment.provider || '—',
|
||||
amount: formatMoneyCents(payment.amount, payment.currency),
|
||||
status: humanizeStatus(payment.status),
|
||||
url: '',
|
||||
@@ -333,6 +380,7 @@ const BillingSection = (): JSX.Element => {
|
||||
|
||||
const openPortal = async (intent: PortalIntent) => {
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setPortalLoading(true);
|
||||
try {
|
||||
const returnUrl = `${window.location.origin}/account/`;
|
||||
@@ -356,8 +404,55 @@ const BillingSection = (): JSX.Element => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
|
||||
const handleNativePurchase = async (planSlug?: string, source = 'account_billing') => {
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||
try {
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, {
|
||||
source,
|
||||
...(planSlug ? { plan_slug: planSlug } : {}),
|
||||
});
|
||||
await purchasePlan(planSlug);
|
||||
setActionNotice(
|
||||
'Purchase submitted. Entitlements update after the store confirms — tap Refresh shortly.'
|
||||
);
|
||||
await loadBilling();
|
||||
} catch (error: unknown) {
|
||||
if (isPurchaseCancelledError(error)) {
|
||||
setActionNotice('Purchase cancelled.');
|
||||
return;
|
||||
}
|
||||
setActionError(
|
||||
apiErrorMessage(error, 'Store purchase failed. Try again or Restore purchases.')
|
||||
);
|
||||
} finally {
|
||||
setCheckoutLoadingSlug(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setRestoreLoading(true);
|
||||
try {
|
||||
await restorePurchases();
|
||||
setActionNotice('Purchases restored. Refreshing billing…');
|
||||
await loadBilling();
|
||||
} catch (error: unknown) {
|
||||
setActionError(apiErrorMessage(error, 'Could not restore purchases. Try again.'));
|
||||
} finally {
|
||||
setRestoreLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartCheckout = async (planSlug?: string, source = 'account_billing') => {
|
||||
if (native) {
|
||||
await handleNativePurchase(planSlug, source);
|
||||
return;
|
||||
}
|
||||
setActionError('');
|
||||
setActionNotice('');
|
||||
setCheckoutLoadingSlug(planSlug || '__default__');
|
||||
try {
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
@@ -395,6 +490,10 @@ const BillingSection = (): JSX.Element => {
|
||||
setShowPlanPicker(true);
|
||||
return;
|
||||
}
|
||||
if (native && (storeSub || showStoreManage)) {
|
||||
void handleNativePurchase(undefined, 'account_upgrade');
|
||||
return;
|
||||
}
|
||||
void openPortal('upgrade');
|
||||
};
|
||||
|
||||
@@ -405,10 +504,24 @@ const BillingSection = (): JSX.Element => {
|
||||
setShowPlanPicker(true);
|
||||
return;
|
||||
}
|
||||
if (native && (storeSub || showStoreManage)) {
|
||||
setActionNotice(
|
||||
`Change or cancel your plan in ${storeLabel} subscription settings.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openPortal('change');
|
||||
};
|
||||
|
||||
const handleConfirmCancel = async () => {
|
||||
if (native && (storeSub || showStoreManage) && !showStripeManage) {
|
||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'store' });
|
||||
setCancelConfirmOpen(false);
|
||||
setActionNotice(
|
||||
`Open ${storeLabel} → Subscriptions to cancel. Access usually continues until the period end.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
trackEvent(AnalyticsEvents.SUBSCRIPTION_CANCEL_STARTED, { source: 'portal' });
|
||||
setCancelConfirmOpen(false);
|
||||
await openPortal('cancel');
|
||||
@@ -422,7 +535,6 @@ const BillingSection = (): JSX.Element => {
|
||||
source: 'plan_picker',
|
||||
plan_slug: plan.slug,
|
||||
});
|
||||
// New higher tier via Checkout when selectable; portal otherwise.
|
||||
await handleStartCheckout(plan.slug, 'account_upgrade');
|
||||
return;
|
||||
}
|
||||
@@ -430,7 +542,12 @@ const BillingSection = (): JSX.Element => {
|
||||
source: 'plan_picker',
|
||||
plan_slug: plan.slug,
|
||||
});
|
||||
// Existing subscribers change plans in the Stripe portal (proration / PCI).
|
||||
if (native && (storeSub || showStoreManage) && !showStripeManage) {
|
||||
setShowPlanPicker(false);
|
||||
await handleNativePurchase(plan.slug, 'account_change');
|
||||
return;
|
||||
}
|
||||
// Existing Stripe subscribers change plans in the portal (proration / PCI).
|
||||
setShowPlanPicker(false);
|
||||
await openPortal('change');
|
||||
};
|
||||
@@ -525,7 +642,7 @@ const BillingSection = (): JSX.Element => {
|
||||
|
||||
{!loading && !listError && (
|
||||
<ButtonRow>
|
||||
{hasPortalAccess && !complimentary ? (
|
||||
{showStripeManage ? (
|
||||
<>
|
||||
<StyledButton
|
||||
type="button"
|
||||
@@ -564,14 +681,74 @@ const BillingSection = (): JSX.Element => {
|
||||
Complimentary access — no payment required. Plan changes and
|
||||
cancellation are not available for this account.
|
||||
</BodyText>
|
||||
) : showStoreManage ? (
|
||||
<>
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={handleUpgradeClick}
|
||||
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||
>
|
||||
Upgrade
|
||||
</StyledButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleChangePlanClick}
|
||||
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||
>
|
||||
Change plan
|
||||
</SecondaryButton>
|
||||
{!subscription?.cancel_at_period_end &&
|
||||
subscription?.status !== 'canceled' ? (
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => setCancelConfirmOpen(true)}
|
||||
disabled={Boolean(checkoutLoadingSlug)}
|
||||
>
|
||||
Cancel
|
||||
</DangerButton>
|
||||
) : null}
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActionNotice(
|
||||
`Manage billing in ${storeLabel} → Subscriptions. Changes sync here after refresh.`
|
||||
)
|
||||
}
|
||||
data-testid="store-manage-hint"
|
||||
>
|
||||
Manage in {storeLabel}
|
||||
</SecondaryButton>
|
||||
{native ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
|
||||
data-testid="restore-purchases"
|
||||
>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={() => handleStartCheckout()}
|
||||
disabled={Boolean(checkoutLoadingSlug)}
|
||||
>
|
||||
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
||||
</StyledButton>
|
||||
<>
|
||||
<StyledButton
|
||||
type="button"
|
||||
onClick={() => handleStartCheckout()}
|
||||
disabled={Boolean(checkoutLoadingSlug) || restoreLoading}
|
||||
>
|
||||
{checkoutLoadingSlug ? 'Starting…' : 'Complete payment'}
|
||||
</StyledButton>
|
||||
{native ? (
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
disabled={restoreLoading || Boolean(checkoutLoadingSlug)}
|
||||
data-testid="restore-purchases"
|
||||
>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore purchases'}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<SecondaryButton type="button" onClick={loadBilling} disabled={loading}>
|
||||
Refresh
|
||||
@@ -583,8 +760,12 @@ const BillingSection = (): JSX.Element => {
|
||||
<div data-testid="plan-picker">
|
||||
<BodyText style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||
{planPickerMode === 'upgrade'
|
||||
? 'Choose a higher plan. Checkout opens securely in Stripe.'
|
||||
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
||||
? native && !showStripeManage
|
||||
? 'Choose a higher plan. Purchase completes in the app store.'
|
||||
: 'Choose a higher plan. Checkout opens securely in Stripe.'
|
||||
: native && !showStripeManage
|
||||
? 'Select another plan to purchase via the app store.'
|
||||
: 'Select another plan, then confirm the change in the Stripe customer portal (price and quotas update there).'}
|
||||
</BodyText>
|
||||
<PlanList>
|
||||
{pickerPlans.map((plan) => (
|
||||
@@ -624,6 +805,9 @@ const BillingSection = (): JSX.Element => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionNotice ? (
|
||||
<NoticeText data-testid="billing-action-notice">{actionNotice}</NoticeText>
|
||||
) : null}
|
||||
{actionError ? <ErrorText role="alert">{actionError}</ErrorText> : null}
|
||||
</GlassCard>
|
||||
|
||||
@@ -641,6 +825,7 @@ const BillingSection = (): JSX.Element => {
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>Date</Th>
|
||||
<Th>Provider</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Amount</Th>
|
||||
<Th>Status</Th>
|
||||
@@ -651,6 +836,9 @@ const BillingSection = (): JSX.Element => {
|
||||
{historyRows.map((row) => (
|
||||
<tr key={row.key}>
|
||||
<Td>{formatBillingDate(row.date)}</Td>
|
||||
<Td>
|
||||
<ProviderBadge>{row.provider}</ProviderBadge>
|
||||
</Td>
|
||||
<Td>{row.description}</Td>
|
||||
<Td>{row.amount}</Td>
|
||||
<Td>{row.status}</Td>
|
||||
@@ -685,9 +873,9 @@ const BillingSection = (): JSX.Element => {
|
||||
>
|
||||
<ModalTitle id="cancel-subscription-title">Cancel subscription?</ModalTitle>
|
||||
<BodyText>
|
||||
You will finish canceling in the Stripe customer portal. Access typically
|
||||
continues until the end of the current billing period
|
||||
{periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.
|
||||
{native && (storeSub || showStoreManage) && !showStripeManage
|
||||
? `You will cancel in ${storeLabel} subscription settings. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`
|
||||
: `You will finish canceling in the Stripe customer portal. Access typically continues until the end of the current billing period${periodEndLabel !== '—' ? ` (${periodEndLabel})` : ''}.`}
|
||||
</BodyText>
|
||||
<ButtonRow>
|
||||
<DangerButton
|
||||
@@ -695,7 +883,11 @@ const BillingSection = (): JSX.Element => {
|
||||
onClick={handleConfirmCancel}
|
||||
disabled={portalLoading}
|
||||
>
|
||||
{portalLoading ? 'Opening…' : 'Continue to cancel'}
|
||||
{native && (storeSub || showStoreManage) && !showStripeManage
|
||||
? 'Got it'
|
||||
: portalLoading
|
||||
? 'Opening…'
|
||||
: 'Continue to cancel'}
|
||||
</DangerButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import ConversationDetailCard, {
|
||||
injectCitationMarkers,
|
||||
} from './ConversationDetailCard';
|
||||
|
||||
const theme = {
|
||||
main: '#336699',
|
||||
focus: '#224466',
|
||||
darkMode: true,
|
||||
colors: {
|
||||
text: '#ffffff',
|
||||
cardBackground: 'rgba(0,0,0,0.3)',
|
||||
cardBorder: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
const renderCard = (props: React.ComponentProps<typeof ConversationDetailCard>) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={theme as never}>
|
||||
<ConversationDetailCard {...props} />
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('ConversationDetailCard citations (#98)', () => {
|
||||
it('injects citation markers outside code fences', () => {
|
||||
const md = 'See [1] and [2, 3]\n\n```\n[9]\n```';
|
||||
const out = injectCitationMarkers(md);
|
||||
expect(out).toContain('<citation indices="1"></citation>');
|
||||
expect(out).toContain('<citation indices="2,3"></citation>');
|
||||
expect(out).toContain('```\n[9]\n```');
|
||||
});
|
||||
|
||||
it('renders Sources list from citations prop', () => {
|
||||
renderCard({
|
||||
message: 'Answer with [1]',
|
||||
user_created: false,
|
||||
citations: [
|
||||
{
|
||||
index: 1,
|
||||
title: 'Example Source',
|
||||
url: 'https://example.com/a',
|
||||
published_at: '2026-07-03',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(screen.getByLabelText('Sources')).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: 'Example Source' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/a');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('renders nothing for Sources when citations empty', () => {
|
||||
renderCard({
|
||||
message: 'No sources here',
|
||||
user_created: false,
|
||||
citations: [],
|
||||
});
|
||||
expect(screen.queryByLabelText('Sources')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights source when inline citation clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderCard({
|
||||
message: 'See [1]',
|
||||
user_created: false,
|
||||
citations: [
|
||||
{ index: 1, title: 'Src', url: 'https://example.com', published_at: null },
|
||||
],
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: 'Source 1' }));
|
||||
expect(document.getElementById('citation-source-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,13 @@
|
||||
import React from "react";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { isRagFeatureNotAllowed, parseChatErrorPayload } from "../../utils/chatErrors";
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { isRagFeatureNotAllowed, parseChatErrorPayload } from '../../utils/chatErrors';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
import type { PromptRating } from '../../utils/promptFeedback';
|
||||
import CustomPreBlock from '../CustomPreBlock/CustomPreBlock';
|
||||
import SourcesList from '../SourcesList/SourcesList';
|
||||
import MessageActions from '../MessageActions/MessageActions';
|
||||
|
||||
const fadeIn = keyframes`
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
@@ -12,10 +17,16 @@ const fadeIn = keyframes`
|
||||
const MessageContainer = styled.div<{ $isUser: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: ${(props) => (props.$isUser ? "flex-end" : "flex-start")};
|
||||
align-items: ${(props) => (props.$isUser ? 'flex-end' : 'flex-start')};
|
||||
margin-bottom: 1.5rem;
|
||||
width: 100%;
|
||||
animation: ${fadeIn} 0.3s ease-out;
|
||||
|
||||
&:hover [data-message-actions],
|
||||
&:focus-within [data-message-actions] {
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const Bubble = styled.div<{ $isUser: boolean }>`
|
||||
@@ -28,33 +39,33 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
||||
props.$isUser
|
||||
? `linear-gradient(135deg, ${props.theme.main} 0%, ${props.theme.focus} 100%)`
|
||||
: props.theme.darkMode
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(0, 0, 0, 0.06)"};
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.06)'};
|
||||
color: ${(props) =>
|
||||
props.$isUser || props.theme.darkMode ? "#fff" : props.theme.colors.text};
|
||||
props.$isUser || props.theme.darkMode ? '#fff' : props.theme.colors.text};
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid ${(props) =>
|
||||
props.$isUser
|
||||
? props.theme.darkMode
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(0, 0, 0, 0.1)"
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)'
|
||||
: props.theme.darkMode
|
||||
? "rgba(255, 255, 255, 0.1)"
|
||||
: "rgba(0, 0, 0, 0.08)"};
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.08)'};
|
||||
box-shadow: ${(props) =>
|
||||
props.$isUser || props.theme.darkMode
|
||||
? "0 4px 15px rgba(0, 0, 0, 0.2)"
|
||||
: "0 2px 10px rgba(0, 0, 0, 0.08)"};
|
||||
? '0 4px 15px rgba(0, 0, 0, 0.2)'
|
||||
: '0 2px 10px rgba(0, 0, 0, 0.08)'};
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
border-bottom-right-radius: ${(props) => (props.$isUser ? "0.2rem" : "1.2rem")};
|
||||
border-bottom-left-radius: ${(props) => (props.$isUser ? "1.2rem" : "0.2rem")};
|
||||
border-bottom-right-radius: ${(props) => (props.$isUser ? '0.2rem' : '1.2rem')};
|
||||
border-bottom-left-radius: ${(props) => (props.$isUser ? '1.2rem' : '0.2rem')};
|
||||
|
||||
& pre {
|
||||
background: ${(props) =>
|
||||
props.$isUser || props.theme.darkMode
|
||||
? "rgba(0, 0, 0, 0.3)"
|
||||
: "rgba(0, 0, 0, 0.06)"};
|
||||
? 'rgba(0, 0, 0, 0.3)'
|
||||
: 'rgba(0, 0, 0, 0.06)'};
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
@@ -68,7 +79,7 @@ const Bubble = styled.div<{ $isUser: boolean }>`
|
||||
|
||||
& a {
|
||||
color: ${(props) =>
|
||||
props.$isUser || props.theme.darkMode ? "#a0c4ff" : props.theme.main};
|
||||
props.$isUser || props.theme.darkMode ? '#a0c4ff' : props.theme.main};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -129,9 +140,54 @@ const UpgradeLink = styled(Link)`
|
||||
}
|
||||
`;
|
||||
|
||||
const CitationButton = styled.button`
|
||||
display: inline;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0 0.25rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: ${({ theme }) =>
|
||||
theme.darkMode ? 'rgba(160, 196, 255, 0.2)' : 'rgba(51, 102, 153, 0.12)'};
|
||||
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
vertical-align: baseline;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${({ theme }) => theme.main};
|
||||
outline-offset: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Linkify [n] / [1, 2] outside fenced code blocks. */
|
||||
export function injectCitationMarkers(markdown: string): string {
|
||||
const parts = markdown.split(/(```[\s\S]*?```)/g);
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith('```')) return part;
|
||||
return part.replace(
|
||||
/\[(\d+(?:\s*,\s*\d+)*)\]/g,
|
||||
(_match, nums: string) =>
|
||||
`<citation indices="${nums.replace(/\s+/g, '')}"></citation>`,
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
type ConversationDetailCardProps = {
|
||||
message: string;
|
||||
user_created: boolean;
|
||||
promptId?: number;
|
||||
citations?: Citation[];
|
||||
isStreaming?: boolean;
|
||||
isLast?: boolean;
|
||||
conversationTitle?: string;
|
||||
createdTimestamp?: Date | string | null;
|
||||
initialRating?: PromptRating | null;
|
||||
onRatingChange?: (rating: PromptRating | null) => void;
|
||||
};
|
||||
|
||||
const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
@@ -139,7 +195,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
style={{ maxWidth: "100%", height: "auto", borderRadius: "8px", marginTop: "10px" }}
|
||||
style={{ maxWidth: '100%', height: 'auto', borderRadius: '8px', marginTop: '10px' }}
|
||||
alt="plot"
|
||||
/>
|
||||
);
|
||||
@@ -147,7 +203,7 @@ const MyPlot = ({ format, image }: { format: string; image: string }) => {
|
||||
|
||||
const MyError = ({ content }: { content: string }) => {
|
||||
return (
|
||||
<span style={{ color: "#ff6b6b", fontWeight: "bold", display: "block", marginTop: "0.5rem" }}>
|
||||
<span style={{ color: '#ff6b6b', fontWeight: 'bold', display: 'block', marginTop: '0.5rem' }}>
|
||||
Error: {content}
|
||||
</span>
|
||||
);
|
||||
@@ -156,7 +212,59 @@ const MyError = ({ content }: { content: string }) => {
|
||||
const ConversationDetailCard = ({
|
||||
message,
|
||||
user_created,
|
||||
promptId,
|
||||
citations = [],
|
||||
isStreaming = false,
|
||||
isLast = false,
|
||||
conversationTitle = 'conversation',
|
||||
createdTimestamp = null,
|
||||
initialRating = null,
|
||||
onRatingChange,
|
||||
}: ConversationDetailCardProps): JSX.Element => {
|
||||
const [highlightIndex, setHighlightIndex] = useState<number | null>(null);
|
||||
|
||||
const CitationMark = useCallback(
|
||||
({ indices }: { indices?: string }) => {
|
||||
const list = (indices || '')
|
||||
.split(',')
|
||||
.map((n) => Number(n.trim()))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
if (!list.length) return null;
|
||||
return (
|
||||
<>
|
||||
{list.map((index, i) => {
|
||||
const citation = citations.find((c) => c.index === index);
|
||||
return (
|
||||
<CitationButton
|
||||
key={`${index}-${i}`}
|
||||
type="button"
|
||||
aria-label={`Source ${index}`}
|
||||
title={citation?.title || `Source ${index}`}
|
||||
onClick={() => {
|
||||
setHighlightIndex(index);
|
||||
if (citation?.url) {
|
||||
// Prefer scroll/highlight; URL still available from Sources list
|
||||
}
|
||||
const el = document.getElementById(`citation-source-${index}`);
|
||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||
}}
|
||||
>
|
||||
[{index}]
|
||||
</CitationButton>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[citations],
|
||||
);
|
||||
|
||||
const displayMarkdown = useMemo(() => {
|
||||
if (!message) return message;
|
||||
if (!citations.length || user_created) return message;
|
||||
return injectCitationMarkers(message);
|
||||
}, [message, citations, user_created]);
|
||||
|
||||
if (message.length === 0) {
|
||||
return (
|
||||
<MessageContainer $isUser={false}>
|
||||
@@ -188,36 +296,45 @@ const ConversationDetailCard = ({
|
||||
);
|
||||
}
|
||||
|
||||
let contentToAdd = message;
|
||||
let contentToAdd = displayMarkdown;
|
||||
let rawForCopy = message;
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message);
|
||||
if (
|
||||
parsedMessage &&
|
||||
typeof parsedMessage === "object" &&
|
||||
typeof parsedMessage === 'object' &&
|
||||
parsedMessage.type
|
||||
) {
|
||||
switch (parsedMessage.type) {
|
||||
case "text":
|
||||
contentToAdd = parsedMessage.content;
|
||||
case 'text':
|
||||
rawForCopy = parsedMessage.content;
|
||||
contentToAdd =
|
||||
citations.length && !user_created
|
||||
? injectCitationMarkers(parsedMessage.content)
|
||||
: parsedMessage.content;
|
||||
break;
|
||||
case "plot":
|
||||
case 'plot':
|
||||
contentToAdd = `<plot format="${parsedMessage.format}" image="${parsedMessage.image}"></plot>`;
|
||||
rawForCopy = message;
|
||||
break;
|
||||
case "error":
|
||||
case 'error':
|
||||
contentToAdd = `<error content="${parsedMessage.content}"></error>`;
|
||||
rawForCopy = message;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
} catch {
|
||||
/* plain markdown */
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageContainer $isUser={user_created}>
|
||||
<Bubble $isUser={user_created}>
|
||||
<Markdown
|
||||
className="display-linebreak"
|
||||
style={{ whiteSpace: "pre-line" }}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
options={{
|
||||
overrides: {
|
||||
plot: {
|
||||
@@ -226,12 +343,35 @@ const ConversationDetailCard = ({
|
||||
error: {
|
||||
component: MyError,
|
||||
},
|
||||
pre: {
|
||||
component: CustomPreBlock,
|
||||
},
|
||||
citation: {
|
||||
component: CitationMark,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{contentToAdd}
|
||||
</Markdown>
|
||||
</Bubble>
|
||||
|
||||
{!user_created && citations.length > 0 && (
|
||||
<SourcesList citations={citations} highlightIndex={highlightIndex} />
|
||||
)}
|
||||
|
||||
<MessageActions
|
||||
promptId={promptId}
|
||||
rawMarkdown={rawForCopy}
|
||||
userCreated={user_created}
|
||||
isStreaming={isStreaming}
|
||||
isLast={isLast}
|
||||
conversationTitle={conversationTitle}
|
||||
createdTimestamp={createdTimestamp}
|
||||
citations={citations}
|
||||
initialRating={initialRating}
|
||||
onRatingChange={onRatingChange}
|
||||
/>
|
||||
</MessageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,25 +1,72 @@
|
||||
import { PrismLight as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import tsx from "react-syntax-highlighter/dist/cjs/languages/prism/tsx";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import tsx from 'react-syntax-highlighter/dist/cjs/languages/prism/tsx';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import Check from '@mui/icons-material/Check';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { showToast } from '../../utils/toastBus';
|
||||
|
||||
SyntaxHighlighter.registerLanguage("tsx", tsx);
|
||||
SyntaxHighlighter.registerLanguage('tsx', tsx);
|
||||
|
||||
type CustomCodeBlock = {
|
||||
children: string,
|
||||
className: string
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
}
|
||||
const CopyBtn = styled(IconButton)`
|
||||
&& {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
padding: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type CustomCodeBlockProps = {
|
||||
children: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CustomCodeBlock = ({children, className}: CustomCodeBlock): JSX.Element => {
|
||||
const language = className?.replace("lang-","");
|
||||
return (
|
||||
<SyntaxHighlighter language={language} style={oneDark}>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
const CustomCodeBlock = ({
|
||||
children,
|
||||
className,
|
||||
}: CustomCodeBlockProps): JSX.Element => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = className?.replace('lang-', '').replace('language-', '') || undefined;
|
||||
const code = typeof children === 'string' ? children : String(children ?? '');
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(code.replace(/\n$/, ''));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
showToast('Could not copy code', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Tooltip title={copied ? 'Copied' : 'Copy code'}>
|
||||
<CopyBtn aria-label="Copy code block" size="small" onClick={() => void handleCopy()}>
|
||||
{copied ? <Check fontSize="inherit" /> : <ContentCopy fontSize="inherit" />}
|
||||
</CopyBtn>
|
||||
</Tooltip>
|
||||
<SyntaxHighlighter language={language} style={oneDark}>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCodeBlock;
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import CustomCodeBlock from "../CustomCodeBlock/CustomCodeBlock"
|
||||
import CustomCodeBlock from '../CustomCodeBlock/CustomCodeBlock';
|
||||
|
||||
type CustomPreBlockProps = {
|
||||
children: JSX.Element | JSX.Element[]
|
||||
}
|
||||
children: JSX.Element | JSX.Element[];
|
||||
};
|
||||
|
||||
const CustomPreBlock = ({children, ...rest}: CustomPreBlockProps): JSX.Element => {
|
||||
if ("type" in children && children["type"] === "code") {
|
||||
return CustomCodeBlock({children: children["props"]["children"], className: children["props"]["className"] });
|
||||
}
|
||||
|
||||
return <pre {...rest}>{children}</pre>
|
||||
}
|
||||
export default CustomPreBlock;
|
||||
const CustomPreBlock = ({ children, ...rest }: CustomPreBlockProps): JSX.Element => {
|
||||
const child = Array.isArray(children) ? children[0] : children;
|
||||
if (child && typeof child === 'object' && 'type' in child && child.type === 'code') {
|
||||
return (
|
||||
<CustomCodeBlock
|
||||
className={(child.props as { className?: string }).className}
|
||||
>
|
||||
{(child.props as { children?: string }).children as string}
|
||||
</CustomCodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return <pre {...rest}>{children}</pre>;
|
||||
};
|
||||
|
||||
export default CustomPreBlock;
|
||||
|
||||
@@ -44,6 +44,10 @@ jest.mock('../../utils/analytics', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logOutRevenueCat: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
main: '#4a90e2',
|
||||
darkMode: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||
import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { AuthContext } from '../../contexts/AuthContext';
|
||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||
import { logOutRevenueCat } from '../../utils/revenueCat';
|
||||
|
||||
const GlassCard = styled.div`
|
||||
background: ${({ theme }) => theme.colors.cardBackground};
|
||||
@@ -180,6 +181,7 @@ const DeleteAccountSection = (): JSX.Element => {
|
||||
applyAccessToken(null);
|
||||
setAuthentication(false);
|
||||
setAccount(undefined);
|
||||
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
||||
navigate('/signin/');
|
||||
} catch (err: unknown) {
|
||||
trackEvent(AnalyticsEvents.ACCOUNT_DELETE_FAILED);
|
||||
|
||||
@@ -19,6 +19,10 @@ jest.mock('../../../axiosApi', () => ({
|
||||
applyAccessToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logOutRevenueCat: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const theme = {
|
||||
main: '#4a90e2',
|
||||
focus: '#224466',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { applyAccessToken, axiosInstance } from '../../../axiosApi';
|
||||
import { clearTokens, getRefreshToken } from '../../auth/tokenStorage';
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import { logOutRevenueCat } from '../../utils/revenueCat';
|
||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||
|
||||
const HeaderContainer = styled.header`
|
||||
@@ -230,6 +231,7 @@ const Header2 = ({
|
||||
applyAccessToken(null);
|
||||
setAuthentication(false)
|
||||
setAccount(undefined);
|
||||
void Promise.resolve(logOutRevenueCat()).catch((err) => console.warn('RevenueCat logOut', err));
|
||||
navigate('/signin/')
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Popover,
|
||||
Chip,
|
||||
TextField,
|
||||
Button,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import ContentCopy from '@mui/icons-material/ContentCopy';
|
||||
import Check from '@mui/icons-material/Check';
|
||||
import ThumbUp from '@mui/icons-material/ThumbUp';
|
||||
import ThumbUpOutlined from '@mui/icons-material/ThumbUpOutlined';
|
||||
import ThumbDown from '@mui/icons-material/ThumbDown';
|
||||
import ThumbDownOutlined from '@mui/icons-material/ThumbDownOutlined';
|
||||
import FileDownload from '@mui/icons-material/FileDownload';
|
||||
import { copyTextToClipboard } from '../../utils/clipboard';
|
||||
import { showToast } from '../../utils/toastBus';
|
||||
import {
|
||||
clearPromptFeedback,
|
||||
upsertPromptFeedback,
|
||||
type PromptRating,
|
||||
} from '../../utils/promptFeedback';
|
||||
import {
|
||||
exportChat,
|
||||
type ExportFormat,
|
||||
type ExportTurn,
|
||||
} from '../../utils/export/exportChat';
|
||||
import { AnalyticsEvents, trackEvent } from '../../utils/analytics';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
|
||||
const DOWN_REASONS: { code: string; label: string }[] = [
|
||||
{ code: 'incorrect', label: 'Incorrect' },
|
||||
{ code: 'out_of_date', label: 'Out of date' },
|
||||
{ code: 'didnt_follow_instructions', label: "Didn't follow instructions" },
|
||||
{ code: 'unsafe', label: 'Unsafe' },
|
||||
{ code: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
const ActionsRow = styled.div<{ $alwaysVisible: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
margin-top: 0.35rem;
|
||||
min-height: 2rem;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
opacity: ${({ $alwaysVisible }) => ($alwaysVisible ? 1 : 0)};
|
||||
pointer-events: ${({ $alwaysVisible }) => ($alwaysVisible ? 'auto' : 'none')};
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
/* Desktop: revealed via parent :hover / :focus-within when not forced visible */
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const ActionIconButton = styled(IconButton)`
|
||||
&& {
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
opacity: 0.7;
|
||||
padding: 0.35rem;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
background: ${({ theme }) =>
|
||||
theme.darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
}
|
||||
|
||||
&.Mui-disabled {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type MessageActionsProps = {
|
||||
promptId?: number;
|
||||
rawMarkdown: string;
|
||||
userCreated: boolean;
|
||||
isStreaming?: boolean;
|
||||
isLast?: boolean;
|
||||
conversationTitle: string;
|
||||
createdTimestamp?: Date | string | null;
|
||||
citations?: Citation[];
|
||||
initialRating?: PromptRating | null;
|
||||
onRatingChange?: (rating: PromptRating | null) => void;
|
||||
};
|
||||
|
||||
const MessageActions = ({
|
||||
promptId,
|
||||
rawMarkdown,
|
||||
userCreated,
|
||||
isStreaming = false,
|
||||
isLast = false,
|
||||
conversationTitle,
|
||||
createdTimestamp,
|
||||
citations = [],
|
||||
initialRating = null,
|
||||
onRatingChange,
|
||||
}: MessageActionsProps): JSX.Element | null => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [rating, setRating] = useState<PromptRating | null>(initialRating);
|
||||
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);
|
||||
const [reasonAnchor, setReasonAnchor] = useState<null | HTMLElement>(null);
|
||||
const [reasonCode, setReasonCode] = useState<string | null>(null);
|
||||
const [reasonComment, setReasonComment] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRating(initialRating);
|
||||
}, [initialRating, promptId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isStreaming) return null;
|
||||
|
||||
const alwaysVisible = isLast || focused || Boolean(exportAnchor) || Boolean(reasonAnchor);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(rawMarkdown);
|
||||
setCopied(true);
|
||||
trackEvent(AnalyticsEvents.MESSAGE_COPIED, {
|
||||
role: userCreated ? 'user' : 'assistant',
|
||||
});
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
showToast('Could not copy message', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const applyRating = async (next: PromptRating | null) => {
|
||||
if (!promptId) {
|
||||
showToast('Rating unavailable until message is saved', 'info');
|
||||
return;
|
||||
}
|
||||
const prev = rating;
|
||||
setRating(next);
|
||||
onRatingChange?.(next);
|
||||
try {
|
||||
if (next == null) {
|
||||
await clearPromptFeedback(promptId);
|
||||
} else {
|
||||
await upsertPromptFeedback(promptId, { rating: next });
|
||||
}
|
||||
trackEvent(AnalyticsEvents.MESSAGE_RATED, {
|
||||
rating: next ?? 'cleared',
|
||||
});
|
||||
} catch {
|
||||
setRating(prev);
|
||||
onRatingChange?.(prev);
|
||||
showToast('Could not save rating', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumb = async (
|
||||
next: PromptRating,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
) => {
|
||||
if (rating === next) {
|
||||
await applyRating(null);
|
||||
return;
|
||||
}
|
||||
await applyRating(next);
|
||||
if (next === 'down') {
|
||||
setReasonAnchor(event.currentTarget);
|
||||
setReasonCode(null);
|
||||
setReasonComment('');
|
||||
}
|
||||
};
|
||||
|
||||
const submitReason = async () => {
|
||||
if (!promptId || rating !== 'down') {
|
||||
setReasonAnchor(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await upsertPromptFeedback(promptId, {
|
||||
rating: 'down',
|
||||
reason: reasonCode ?? undefined,
|
||||
comment: reasonComment.trim() || undefined,
|
||||
});
|
||||
trackEvent(AnalyticsEvents.MESSAGE_RATING_REASON, {
|
||||
reason: reasonCode ?? 'none',
|
||||
hasComment: Boolean(reasonComment.trim()),
|
||||
});
|
||||
} catch {
|
||||
showToast('Could not save feedback reason', 'error');
|
||||
} finally {
|
||||
setReasonAnchor(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format: ExportFormat) => {
|
||||
setExportAnchor(null);
|
||||
const turn: ExportTurn = {
|
||||
role: userCreated ? 'user' : 'assistant',
|
||||
message: rawMarkdown,
|
||||
timestamp: createdTimestamp ?? null,
|
||||
citations: userCreated ? [] : citations,
|
||||
};
|
||||
try {
|
||||
await exportChat({
|
||||
format,
|
||||
scope: 'message',
|
||||
title: conversationTitle,
|
||||
turns: [turn],
|
||||
});
|
||||
trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, {
|
||||
format,
|
||||
scope: 'message',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('Export failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionsRow
|
||||
ref={rowRef}
|
||||
$alwaysVisible={alwaysVisible}
|
||||
data-message-actions
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!rowRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setFocused(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tooltip title={copied ? 'Copied' : 'Copy markdown'}>
|
||||
<ActionIconButton
|
||||
aria-label="Copy message as markdown"
|
||||
size="small"
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{copied ? <Check fontSize="small" /> : <ContentCopy fontSize="small" />}
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
|
||||
{!userCreated && (
|
||||
<>
|
||||
<Tooltip title="Thumbs up">
|
||||
<span>
|
||||
<ActionIconButton
|
||||
aria-label="Thumbs up"
|
||||
size="small"
|
||||
disabled={!promptId}
|
||||
onClick={(e) => void handleThumb('up', e)}
|
||||
>
|
||||
{rating === 'up' ? (
|
||||
<ThumbUp fontSize="small" />
|
||||
) : (
|
||||
<ThumbUpOutlined fontSize="small" />
|
||||
)}
|
||||
</ActionIconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Thumbs down">
|
||||
<span>
|
||||
<ActionIconButton
|
||||
aria-label="Thumbs down"
|
||||
size="small"
|
||||
disabled={!promptId}
|
||||
onClick={(e) => void handleThumb('down', e)}
|
||||
>
|
||||
{rating === 'down' ? (
|
||||
<ThumbDown fontSize="small" />
|
||||
) : (
|
||||
<ThumbDownOutlined fontSize="small" />
|
||||
)}
|
||||
</ActionIconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip title="Export">
|
||||
<ActionIconButton
|
||||
aria-label="Export message"
|
||||
size="small"
|
||||
onClick={(e) => setExportAnchor(e.currentTarget)}
|
||||
>
|
||||
<FileDownload fontSize="small" />
|
||||
</ActionIconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Menu
|
||||
anchorEl={exportAnchor}
|
||||
open={Boolean(exportAnchor)}
|
||||
onClose={() => setExportAnchor(null)}
|
||||
>
|
||||
{(
|
||||
[
|
||||
['pdf', 'PDF'],
|
||||
['docx', 'Word (.docx)'],
|
||||
['csv', 'CSV'],
|
||||
['xlsx', 'Excel (.xlsx)'],
|
||||
['txt', 'Plain text'],
|
||||
] as [ExportFormat, string][]
|
||||
).map(([format, label]) => (
|
||||
<MenuItem key={format} onClick={() => void handleExport(format)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<Popover
|
||||
open={Boolean(reasonAnchor)}
|
||||
anchorEl={reasonAnchor}
|
||||
onClose={() => setReasonAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
>
|
||||
<Stack spacing={1.25} sx={{ p: 1.5, width: 280, maxWidth: '90vw' }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600 }}>
|
||||
What went wrong? (optional)
|
||||
</div>
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
||||
{DOWN_REASONS.map((reason) => (
|
||||
<Chip
|
||||
key={reason.code}
|
||||
label={reason.label}
|
||||
size="small"
|
||||
color={reasonCode === reason.code ? 'primary' : 'default'}
|
||||
onClick={() => setReasonCode(reason.code)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<TextField
|
||||
size="small"
|
||||
multiline
|
||||
minRows={2}
|
||||
placeholder="Additional details (optional)"
|
||||
value={reasonComment}
|
||||
onChange={(e) => setReasonComment(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={() => void submitReason()}>
|
||||
Done
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover>
|
||||
</ActionsRow>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageActions;
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { Citation } from '../../utils/wsFrames';
|
||||
|
||||
const SourcesRoot = styled.aside`
|
||||
margin-top: 0.65rem;
|
||||
max-width: 80%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid ${({ theme }) => theme.colors.cardBorder};
|
||||
background: ${({ theme }) =>
|
||||
theme.darkMode ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.03)'};
|
||||
color: ${({ theme }) => theme.colors.text};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
max-width: 92%;
|
||||
}
|
||||
`;
|
||||
|
||||
const SourcesTitle = styled.h4`
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
`;
|
||||
|
||||
const SourceList = styled.ol`
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
`;
|
||||
|
||||
const SourceItem = styled.li<{ $highlight: boolean }>`
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.15rem 0.25rem;
|
||||
outline: ${({ $highlight, theme }) =>
|
||||
$highlight ? `2px solid ${theme.main}` : 'none'};
|
||||
background: ${({ $highlight, theme }) =>
|
||||
$highlight
|
||||
? theme.darkMode
|
||||
? 'rgba(255,255,255,0.08)'
|
||||
: 'rgba(0,0,0,0.06)'
|
||||
: 'transparent'};
|
||||
transition: background 0.2s ease, outline 0.2s ease;
|
||||
`;
|
||||
|
||||
const SourceLink = styled.a`
|
||||
color: ${({ theme }) => (theme.darkMode ? '#a0c4ff' : theme.main)};
|
||||
text-decoration: underline;
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const SourceMeta = styled.span`
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.65;
|
||||
margin-top: 0.1rem;
|
||||
`;
|
||||
|
||||
function domainFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
type SourcesListProps = {
|
||||
citations: Citation[];
|
||||
highlightIndex?: number | null;
|
||||
};
|
||||
|
||||
const SourcesList = ({
|
||||
citations,
|
||||
highlightIndex = null,
|
||||
}: SourcesListProps): JSX.Element | null => {
|
||||
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
|
||||
const [active, setActive] = useState<number | null>(highlightIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(highlightIndex ?? null);
|
||||
if (highlightIndex == null) return;
|
||||
const el = itemRefs.current[highlightIndex];
|
||||
el?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [highlightIndex]);
|
||||
|
||||
if (!citations.length) return null;
|
||||
|
||||
const sorted = [...citations].sort((a, b) => a.index - b.index);
|
||||
|
||||
return (
|
||||
<SourcesRoot aria-label="Sources">
|
||||
<SourcesTitle>Sources</SourcesTitle>
|
||||
<SourceList>
|
||||
{sorted.map((citation) => {
|
||||
const domain = domainFromUrl(citation.url);
|
||||
return (
|
||||
<SourceItem
|
||||
key={citation.index}
|
||||
id={`citation-source-${citation.index}`}
|
||||
$highlight={active === citation.index}
|
||||
ref={(node) => {
|
||||
itemRefs.current[citation.index] = node;
|
||||
}}
|
||||
>
|
||||
{citation.url ? (
|
||||
<SourceLink
|
||||
href={citation.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{citation.title || `Source ${citation.index}`}
|
||||
</SourceLink>
|
||||
) : (
|
||||
<span>{citation.title || `Source ${citation.index}`}</span>
|
||||
)}
|
||||
{(citation.published_at || domain) && (
|
||||
<SourceMeta>
|
||||
{[citation.published_at, domain].filter(Boolean).join(' · ')}
|
||||
</SourceMeta>
|
||||
)}
|
||||
</SourceItem>
|
||||
);
|
||||
})}
|
||||
</SourceList>
|
||||
</SourcesRoot>
|
||||
);
|
||||
};
|
||||
|
||||
export default SourcesList;
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Snackbar, Alert } from '@mui/material';
|
||||
import { subscribeToast } from '../../utils/toastBus';
|
||||
|
||||
/**
|
||||
* Global snackbar host for copy/export/feedback errors (#97).
|
||||
*/
|
||||
const ToastHost = (): JSX.Element => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [severity, setSeverity] = useState<'error' | 'success' | 'info'>('info');
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToast((nextMessage, nextSeverity) => {
|
||||
setMessage(nextMessage);
|
||||
setSeverity(nextSeverity);
|
||||
setOpen(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setOpen(false)}
|
||||
severity={severity}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastHost;
|
||||
@@ -3,6 +3,7 @@ import { Account, AccountType } from "../data";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { axiosInstance } from "../../axiosApi";
|
||||
import { logInRevenueCat } from "../utils/revenueCat";
|
||||
|
||||
type AccountProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -28,6 +29,7 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
||||
|
||||
const account: Account = new Account({
|
||||
id: get_user_response.data.id,
|
||||
email: get_user_response.data.email,
|
||||
first_name: get_user_response.data.first_name,
|
||||
last_name: get_user_response.data.last_name,
|
||||
@@ -43,6 +45,9 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
||||
|
||||
});
|
||||
setAccount(account);
|
||||
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||
console.warn('RevenueCat logIn', err)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
@@ -61,4 +66,4 @@ const AccountProvider = ({children}: AccountProviderProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export { AccountContext, AccountProvider }
|
||||
export { AccountContext, AccountProvider }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConversationPrompt, ConversationPromptType } from "../data";
|
||||
import { axiosInstance } from "../../axiosApi";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
|
||||
import { parseCitationsData, parseVersionedFrame } from "../utils/wsFrames";
|
||||
|
||||
type MessageProviderProps ={
|
||||
children? : ReactNode;
|
||||
@@ -53,6 +54,7 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
const selectedConversationRef = useRef<undefined | number>(undefined)
|
||||
const isGeneratingRef = useRef(false)
|
||||
const prevReconnectGenerationRef = useRef(0)
|
||||
const refetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearStreamInterrupted = () => setStreamInterrupted(false)
|
||||
|
||||
@@ -76,8 +78,11 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
message: item.message,
|
||||
user_created: item.user_created,
|
||||
created_timestamp: item.created_timestamp,
|
||||
created: item.created,
|
||||
tokens_in: item.tokens_in ?? null,
|
||||
tokens_out: item.tokens_out ?? null,
|
||||
citations: item.citations ?? [],
|
||||
feedback: item.feedback ?? null,
|
||||
}),
|
||||
)
|
||||
if (tempConversations.length === 1) {
|
||||
@@ -93,10 +98,27 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePostStreamRefetch = () => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
||||
const conversationId = selectedConversationRef.current
|
||||
if (!conversationId) return
|
||||
// Backend saves the assistant prompt after END (+ citations frame); brief delay
|
||||
// hydrates prompt ids, persisted citations, and feedback.
|
||||
refetchTimerRef.current = setTimeout(() => {
|
||||
void GetConversationDetails(conversationId)
|
||||
}, 800)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
GetConversationDetails(selectedConversation)
|
||||
}, [selectedConversation])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (refetchTimerRef.current) clearTimeout(refetchTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Streaming recovery: on reconnect after a drop mid-stream, refetch conversation
|
||||
// and mark the in-flight assistant turn interrupted (no resume protocol on backend).
|
||||
useEffect(() => {
|
||||
@@ -147,21 +169,46 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
|
||||
/* subscribe to channel and register callback */
|
||||
subscribe(channelName, (message: string) => {
|
||||
/* when a message is received just add it to the UI */
|
||||
|
||||
/* Versioned frames (citations #98, status #96) — ignore unknown types safely */
|
||||
const frame = parseVersionedFrame(message)
|
||||
if (frame) {
|
||||
if (frame.type === 'citations') {
|
||||
const citations = parseCitationsData(frame.data)
|
||||
const details = [...conversationRef.current]
|
||||
for (let i = details.length - 1; i >= 0; i -= 1) {
|
||||
if (!details[i].user_created && details[i].message) {
|
||||
details[i] = new ConversationPrompt({
|
||||
...details[i],
|
||||
citations,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
conversationRef.current = details
|
||||
setConversationDetails(details)
|
||||
schedulePostStreamRefetch()
|
||||
}
|
||||
// status / unknown types: no-op (compatible with #96)
|
||||
return
|
||||
}
|
||||
|
||||
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
messageResponsePart.current = 0
|
||||
|
||||
conversationRef.current.pop()
|
||||
|
||||
//handleAssistantPrompt({prompt: messageRef.current})
|
||||
setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
|
||||
console.log([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
|
||||
const finalized = new ConversationPrompt({
|
||||
message: `${messageRef.current}`,
|
||||
user_created: false,
|
||||
})
|
||||
conversationRef.current = [...conversationRef.current, finalized]
|
||||
setConversationDetails([...conversationRef.current])
|
||||
messageRef.current = ''
|
||||
setStateMessage('')
|
||||
isGeneratingRef.current = false
|
||||
setIsGeneratingMessage(false)
|
||||
setStreamInterrupted(false)
|
||||
schedulePostStreamRefetch()
|
||||
}
|
||||
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
||||
conversationRef.current = conversationDetails
|
||||
@@ -176,23 +223,26 @@ const MessageProvider = ( {children}: MessageProviderProps) => {
|
||||
setStreamInterrupted(false)
|
||||
messageResponsePart.current = 1
|
||||
}else{
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
if (messageResponsePart.current === 1){
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
// this has to do with the conversation id
|
||||
if(!selectedConversation){
|
||||
const conversationId = Number(message);
|
||||
setSelectedConversation(conversationId)
|
||||
selectedConversationRef.current = conversationId
|
||||
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (messageResponsePart.current === 2){
|
||||
isGeneratingRef.current = true
|
||||
setIsGeneratingMessage(true)
|
||||
messageRef.current += message
|
||||
setStateMessage(messageRef.current)
|
||||
|
||||
}
|
||||
// ignore stray frames outside an active stream phase
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { Citation } from './utils/wsFrames';
|
||||
import type { PromptRating } from './utils/promptFeedback';
|
||||
|
||||
export type PromptFeedbackState = {
|
||||
rating: PromptRating;
|
||||
reason?: string | null;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
/* Classes for the project */
|
||||
export interface ConversationPromptType {
|
||||
id: number,
|
||||
message: string,
|
||||
user_created: boolean,
|
||||
created_timestamp: Date,
|
||||
created_timestamp?: Date,
|
||||
created?: Date | string,
|
||||
tokens_in?: number | null,
|
||||
tokens_out?: number | null,
|
||||
citations?: Citation[],
|
||||
feedback?: PromptFeedbackState | null,
|
||||
}
|
||||
|
||||
export class ConversationPrompt{
|
||||
@@ -15,6 +27,8 @@ export class ConversationPrompt{
|
||||
created_timestamp: Date = new Date();
|
||||
tokens_in: number | null = null;
|
||||
tokens_out: number | null = null;
|
||||
citations: Citation[] = [];
|
||||
feedback: PromptFeedbackState | null = null;
|
||||
|
||||
constructor(initializer?: any){
|
||||
if(!initializer) return;
|
||||
@@ -22,8 +36,11 @@ export class ConversationPrompt{
|
||||
if (initializer.message) this.message = initializer.message;
|
||||
if (initializer.user_created) this.user_created = initializer.user_created;
|
||||
if (initializer.created_timestamp) this.created_timestamp = initializer.created_timestamp;
|
||||
else if (initializer.created) this.created_timestamp = new Date(initializer.created);
|
||||
if (initializer.tokens_in !== undefined) this.tokens_in = initializer.tokens_in;
|
||||
if (initializer.tokens_out !== undefined) this.tokens_out = initializer.tokens_out;
|
||||
if (Array.isArray(initializer.citations)) this.citations = initializer.citations;
|
||||
if (initializer.feedback !== undefined) this.feedback = initializer.feedback;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -238,6 +255,7 @@ export class AdminAnalytics {
|
||||
}
|
||||
|
||||
export interface AccountType {
|
||||
id?: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
@@ -254,6 +272,7 @@ export interface PreferencesType {
|
||||
}
|
||||
|
||||
export class Account {
|
||||
id?: number;
|
||||
email: string = '';
|
||||
first_name: string ='';
|
||||
last_name: string = '';
|
||||
@@ -265,6 +284,7 @@ export class Account {
|
||||
has_signed_tos: boolean = false;
|
||||
constructor(initializer?: any){
|
||||
if (!initializer) return;
|
||||
if (initializer.id != null) this.id = Number(initializer.id);
|
||||
if (initializer.email) this.email = initializer.email;
|
||||
if (initializer.first_name) this.first_name = initializer.first_name;
|
||||
if (initializer.is_company_manager) this.is_company_manager = initializer.is_company_manager;
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import styled, { ThemeContext } from "styled-components";
|
||||
import { Formik, Form, Field } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { AttachFile, Delete, Send, Close } from "@mui/icons-material"; // Keeping icons for now, can replace later if needed
|
||||
import { Tooltip } from "@mui/material";
|
||||
import { AttachFile, Delete, Send, Close, FileDownload } from "@mui/icons-material";
|
||||
import { Tooltip, Menu, MenuItem, IconButton as MuiIconButton } from "@mui/material";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
|
||||
import {
|
||||
@@ -17,6 +17,9 @@ import ParticleBackground from "../../components/ParticleBackground/ParticleBack
|
||||
|
||||
import Header2 from "../../components/Header2/Header2";
|
||||
import { AnalyticsEvents, trackEvent } from "../../utils/analytics";
|
||||
import { exportChat, type ExportFormat } from "../../utils/export/exportChat";
|
||||
import { showToast } from "../../utils/toastBus";
|
||||
import type { PromptRating } from "../../utils/promptFeedback";
|
||||
|
||||
// Styled Components
|
||||
const PageContainer = styled.div`
|
||||
@@ -152,6 +155,18 @@ const ChatArea = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const ChatToolbar = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 0.25rem 2rem 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 0.25rem 0.85rem 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const InputArea = styled.div`
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
@@ -361,6 +376,57 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [exportAnchor, setExportAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const selectedTitle =
|
||||
conversations.find((c) => c.id === selectedConversation)?.title ||
|
||||
'conversation';
|
||||
|
||||
const handleConversationExport = async (format: ExportFormat) => {
|
||||
setExportAnchor(null);
|
||||
const turns = conversationDetails
|
||||
.filter((d) => d.message.length > 0)
|
||||
.map((d) => ({
|
||||
role: (d.user_created ? 'user' : 'assistant') as 'user' | 'assistant',
|
||||
message: d.message,
|
||||
timestamp: d.created_timestamp,
|
||||
citations: d.citations,
|
||||
}));
|
||||
if (!turns.length) {
|
||||
showToast('Nothing to export yet', 'info');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await exportChat({
|
||||
format,
|
||||
scope: 'conversation',
|
||||
title: selectedTitle,
|
||||
turns,
|
||||
});
|
||||
trackEvent(AnalyticsEvents.MESSAGE_EXPORTED, {
|
||||
format,
|
||||
scope: 'conversation',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('Export failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const updatePromptRating = (promptId: number | undefined, rating: PromptRating | null) => {
|
||||
if (!promptId) return;
|
||||
const next = conversationDetails.map((detail) => {
|
||||
if (detail.id !== promptId) return detail;
|
||||
return new ConversationPrompt({
|
||||
...detail,
|
||||
feedback: rating
|
||||
? { rating, reason: detail.feedback?.reason, comment: detail.feedback?.comment }
|
||||
: null,
|
||||
});
|
||||
});
|
||||
conversationRef.current = next;
|
||||
setConversationDetails(next);
|
||||
};
|
||||
|
||||
const connectionBanner = (() => {
|
||||
if (connectionStatus === ConnectionStatus.CONNECTED || isConnected) {
|
||||
@@ -483,23 +549,66 @@ const AsyncDashboardInner = (): JSX.Element => {
|
||||
</Sidebar>
|
||||
|
||||
<MainContent>
|
||||
{conversationDetails.length > 0 && (
|
||||
<ChatToolbar>
|
||||
<Tooltip title="Export conversation">
|
||||
<MuiIconButton
|
||||
aria-label="Export conversation"
|
||||
size="small"
|
||||
onClick={(e) => setExportAnchor(e.currentTarget)}
|
||||
sx={{ color: theme?.colors?.text }}
|
||||
>
|
||||
<FileDownload fontSize="small" />
|
||||
</MuiIconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={exportAnchor}
|
||||
open={Boolean(exportAnchor)}
|
||||
onClose={() => setExportAnchor(null)}
|
||||
>
|
||||
{(
|
||||
[
|
||||
['pdf', 'PDF'],
|
||||
['docx', 'Word (.docx)'],
|
||||
['csv', 'CSV'],
|
||||
['xlsx', 'Excel (.xlsx)'],
|
||||
['txt', 'Plain text'],
|
||||
] as [ExportFormat, string][]
|
||||
).map(([format, label]) => (
|
||||
<MenuItem key={format} onClick={() => void handleConversationExport(format)}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</ChatToolbar>
|
||||
)}
|
||||
<ChatArea>
|
||||
{conversationDetails.length > 0 ? (
|
||||
conversationDetails.map((convo_detail, index) =>
|
||||
convo_detail.message.length > 0 ? (
|
||||
conversationDetails.map((convo_detail, index) => {
|
||||
const isLast = index === conversationDetails.length - 1;
|
||||
const isLiveStream =
|
||||
!convo_detail.user_created && convo_detail.message.length === 0;
|
||||
const displayMessage = isLiveStream
|
||||
? stateMessage
|
||||
: convo_detail.message;
|
||||
return (
|
||||
<ConversationDetailCard
|
||||
message={convo_detail.message}
|
||||
user_created={convo_detail.user_created}
|
||||
key={convo_detail.id || index}
|
||||
/>
|
||||
) : (
|
||||
<ConversationDetailCard
|
||||
message={stateMessage}
|
||||
message={displayMessage}
|
||||
user_created={convo_detail.user_created}
|
||||
key={convo_detail.id || index}
|
||||
promptId={convo_detail.id}
|
||||
citations={convo_detail.citations}
|
||||
isStreaming={isLiveStream}
|
||||
isLast={isLast}
|
||||
conversationTitle={selectedTitle}
|
||||
createdTimestamp={convo_detail.created_timestamp}
|
||||
initialRating={convo_detail.feedback?.rating ?? null}
|
||||
onRatingChange={(rating) =>
|
||||
updatePromptRating(convo_detail.id, rating)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -7,6 +7,11 @@ import { AccountContext } from '../../contexts/AccountContext';
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logInRevenueCat: () => Promise.resolve(),
|
||||
purchasePlan: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockGet = jest.fn();
|
||||
const mockApplyAccessToken = jest.fn();
|
||||
|
||||
@@ -10,6 +10,9 @@ import { AccountContext } from '../../contexts/AccountContext';
|
||||
import { Account, AccountType } from '../../data';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
||||
import { checkoutReturnUrls } from '../../utils/finance';
|
||||
import { isNativePlatform } from '../../platform/nativePlatform';
|
||||
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -81,14 +84,6 @@ const NavLink = styled(Link)`
|
||||
}
|
||||
`;
|
||||
|
||||
function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
|
||||
const origin = window.location.origin;
|
||||
return {
|
||||
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/billing/cancel`,
|
||||
};
|
||||
}
|
||||
|
||||
const AuthCallback = (): JSX.Element => {
|
||||
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||
const { setAccount } = useContext(AccountContext);
|
||||
@@ -142,6 +137,7 @@ const AuthCallback = (): JSX.Element => {
|
||||
}
|
||||
|
||||
const account = new Account({
|
||||
id: get_user_response.data.id,
|
||||
email: get_user_response.data.email,
|
||||
first_name: get_user_response.data.first_name,
|
||||
last_name: get_user_response.data.last_name,
|
||||
@@ -165,8 +161,23 @@ const AuthCallback = (): JSX.Element => {
|
||||
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'sso' });
|
||||
}
|
||||
identifyAccount(account);
|
||||
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||
console.warn('RevenueCat logIn', err)
|
||||
);
|
||||
|
||||
if (needsCheckout) {
|
||||
if (isNativePlatform()) {
|
||||
setStatusText('Starting store purchase…');
|
||||
try {
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup_native' });
|
||||
await purchasePlan();
|
||||
} catch (purchaseError) {
|
||||
console.warn('RevenueCat purchase after SSO signup', purchaseError);
|
||||
}
|
||||
navigate('/account/', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusText('Starting checkout…');
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'sso_signup' });
|
||||
|
||||
@@ -8,6 +8,10 @@ import { AccountContext } from '../../contexts/AccountContext';
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logInRevenueCat: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockGet = jest.fn();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Account, AccountType } from '../../data';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
||||
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
||||
import { logInRevenueCat } from '../../utils/revenueCat';
|
||||
import styled from 'styled-components';
|
||||
import * as Yup from 'yup';
|
||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||
@@ -221,6 +222,7 @@ const SignIn = (): JSX.Element => {
|
||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/')
|
||||
|
||||
const account = new Account({
|
||||
id: get_user_response.data.id,
|
||||
email: get_user_response.data.email,
|
||||
first_name: get_user_response.data.first_name,
|
||||
last_name: get_user_response.data.last_name,
|
||||
@@ -240,6 +242,9 @@ const SignIn = (): JSX.Element => {
|
||||
setNeedsNewPassword(get_user_response.data.has_usable_password)
|
||||
trackEvent(AnalyticsEvents.LOGIN_SUCCESS, { method: 'password' });
|
||||
identifyAccount(account);
|
||||
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||
console.warn('RevenueCat logIn', err)
|
||||
);
|
||||
if (account.has_signed_tos) {
|
||||
navigate('/');
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,11 @@ import { AccountContext } from '../../contexts/AccountContext';
|
||||
|
||||
jest.mock('../../components/ParticleBackground/ParticleBackground', () => () => null);
|
||||
|
||||
jest.mock('../../utils/revenueCat', () => ({
|
||||
logInRevenueCat: () => Promise.resolve(),
|
||||
purchasePlan: () => Promise.resolve(),
|
||||
}));
|
||||
|
||||
const mockPost = jest.fn();
|
||||
const mockGet = jest.fn();
|
||||
const assignMock = jest.fn();
|
||||
|
||||
@@ -10,6 +10,9 @@ import { Account, AccountType } from '../../data';
|
||||
import ParticleBackground from '../../components/ParticleBackground/ParticleBackground';
|
||||
import SsoButtons, { OAuthProviderFlags } from '../../components/SsoButtons/SsoButtons';
|
||||
import { AnalyticsEvents, identifyAccount, trackEvent } from '../../utils/analytics';
|
||||
import { checkoutReturnUrls } from '../../utils/finance';
|
||||
import { isNativePlatform } from '../../platform/nativePlatform';
|
||||
import { logInRevenueCat, purchasePlan } from '../../utils/revenueCat';
|
||||
import styled from 'styled-components';
|
||||
import * as Yup from 'yup';
|
||||
import hesychiaMark from '../../assets/brand/hesychia-mark.png';
|
||||
@@ -170,14 +173,6 @@ const validationSchema = Yup.object().shape({
|
||||
company_name: Yup.string(),
|
||||
});
|
||||
|
||||
function checkoutReturnUrls(): { success_url: string; cancel_url: string } {
|
||||
const origin = window.location.origin;
|
||||
return {
|
||||
success_url: `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/billing/cancel`,
|
||||
};
|
||||
}
|
||||
|
||||
const SignUp = (): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const { setAuthentication, setNeedsNewPassword } = useContext(AuthContext);
|
||||
@@ -213,6 +208,7 @@ const SignUp = (): JSX.Element => {
|
||||
const loadAccount = async (): Promise<Account> => {
|
||||
const get_user_response: AxiosResponse<AccountType> = await axiosInstance.get('/user/get/');
|
||||
const account = new Account({
|
||||
id: get_user_response.data.id,
|
||||
email: get_user_response.data.email,
|
||||
first_name: get_user_response.data.first_name,
|
||||
last_name: get_user_response.data.last_name,
|
||||
@@ -249,14 +245,31 @@ const SignUp = (): JSX.Element => {
|
||||
const account = await loadAccount();
|
||||
trackEvent(AnalyticsEvents.SIGNUP_SUCCESS, { method: 'password' });
|
||||
identifyAccount(account);
|
||||
void Promise.resolve(logInRevenueCat(account)).catch((err) =>
|
||||
console.warn('RevenueCat logIn', err)
|
||||
);
|
||||
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
const needsCheckout = registerResponse.data?.needs_checkout !== false;
|
||||
if (!needsCheckout) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Native: store IAP via RevenueCat (BillingSection) — skip Stripe Checkout.
|
||||
if (isNativePlatform()) {
|
||||
try {
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup_native' });
|
||||
await purchasePlan();
|
||||
navigate('/account/');
|
||||
return;
|
||||
} catch (purchaseError: unknown) {
|
||||
console.warn('RevenueCat purchase after signup', purchaseError);
|
||||
navigate('/account/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { success_url, cancel_url } = checkoutReturnUrls();
|
||||
trackEvent(AnalyticsEvents.CHECKOUT_STARTED, { source: 'signup' });
|
||||
const checkoutResponse = await axiosInstance.post('/finance/checkout/', {
|
||||
success_url,
|
||||
|
||||
@@ -25,6 +25,7 @@ const queue: QueuedCall[] = [];
|
||||
* | Plan Change Started | Change-plan intent (#75) |
|
||||
* | Subscription Cancel Started | Cancel intent (#75) |
|
||||
* | Account Delete Started / Success / Failed | Self-delete (#34 companion) |
|
||||
* | Message Copied / Rated / Rating Reason / Exported | Message actions (#97) |
|
||||
*/
|
||||
export const AnalyticsEvents = {
|
||||
LOGIN_SUCCESS: 'Login Success',
|
||||
@@ -46,6 +47,10 @@ export const AnalyticsEvents = {
|
||||
ACCOUNT_DELETE_STARTED: 'Account Delete Started',
|
||||
ACCOUNT_DELETE_SUCCESS: 'Account Delete Success',
|
||||
ACCOUNT_DELETE_FAILED: 'Account Delete Failed',
|
||||
MESSAGE_COPIED: 'Message Copied',
|
||||
MESSAGE_RATED: 'Message Rated',
|
||||
MESSAGE_RATING_REASON: 'Message Rating Reason',
|
||||
MESSAGE_EXPORTED: 'Message Exported',
|
||||
} as const;
|
||||
|
||||
export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents];
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { copyTextToClipboard } from './clipboard';
|
||||
|
||||
describe('clipboard', () => {
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses navigator.clipboard.writeText when available', async () => {
|
||||
const writeText = jest.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
await copyTextToClipboard('hello');
|
||||
expect(writeText).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('falls back to execCommand when clipboard API fails', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: jest.fn().mockRejectedValue(new Error('denied')),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
await copyTextToClipboard('fallback text');
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
|
||||
it('falls back when clipboard API is missing', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
await copyTextToClipboard('legacy');
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Clipboard helper with execCommand fallback for Capacitor / insecure contexts (#97).
|
||||
*/
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// fall through to legacy path
|
||||
}
|
||||
}
|
||||
fallbackCopyText(text);
|
||||
}
|
||||
|
||||
function fallbackCopyText(text: string): void {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.top = '0';
|
||||
textarea.style.left = '0';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) {
|
||||
throw new Error('Copy command failed');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Client-side file download. Uses blob URL + anchor click.
|
||||
* Capacitor Android WebView: plain downloads often work for blob URLs;
|
||||
* if not, consider @capacitor/filesystem + Share (#97).
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = 'noopener';
|
||||
anchor.style.display = 'none';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadText(text: string, filename: string, mime = 'text/plain;charset=utf-8'): void {
|
||||
downloadBlob(new Blob([text], { type: mime }), filename);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
buildExportFilename,
|
||||
extractMarkdownTables,
|
||||
parseMarkdownBlocks,
|
||||
sanitizeFilenamePart,
|
||||
stripMarkdown,
|
||||
} from './markdownUtils';
|
||||
import { selectTabularStrategy } from './exportChat';
|
||||
|
||||
describe('export markdown utils', () => {
|
||||
it('sanitises filenames with title + ISO date', () => {
|
||||
expect(sanitizeFilenamePart('Hesychia Taylor Swift Question!')).toBe(
|
||||
'hesychia-taylor-swift-question',
|
||||
);
|
||||
expect(
|
||||
buildExportFilename('Hesychia Taylor Swift Question', 'pdf', new Date('2026-08-02T12:00:00Z')),
|
||||
).toBe('hesychia-taylor-swift-question-2026-08-02.pdf');
|
||||
});
|
||||
|
||||
it('strips markdown for plain text', () => {
|
||||
expect(stripMarkdown('# Hello\n\n**world**')).toContain('Hello');
|
||||
expect(stripMarkdown('# Hello\n\n**world**')).not.toContain('**');
|
||||
});
|
||||
|
||||
it('parses headings, lists, tables, and code', () => {
|
||||
const blocks = parseMarkdownBlocks(
|
||||
[
|
||||
'# Title',
|
||||
'',
|
||||
'- a',
|
||||
'- b',
|
||||
'',
|
||||
'```js',
|
||||
'console.log(1)',
|
||||
'```',
|
||||
'',
|
||||
'| A | B |',
|
||||
'| --- | --- |',
|
||||
'| 1 | 2 |',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(blocks.find((b) => b.type === 'heading')).toMatchObject({
|
||||
level: 1,
|
||||
text: 'Title',
|
||||
});
|
||||
expect(blocks.find((b) => b.type === 'list')).toMatchObject({
|
||||
ordered: false,
|
||||
items: ['a', 'b'],
|
||||
});
|
||||
expect(blocks.find((b) => b.type === 'code')).toMatchObject({
|
||||
language: 'js',
|
||||
text: 'console.log(1)',
|
||||
});
|
||||
expect(blocks.find((b) => b.type === 'table')).toMatchObject({
|
||||
headers: ['A', 'B'],
|
||||
rows: [['1', '2']],
|
||||
});
|
||||
});
|
||||
|
||||
it('selects tabular strategy per CSV/XLSX rules', () => {
|
||||
const withTable = '| A | B |\n| --- | --- |\n| 1 | 2 |';
|
||||
expect(selectTabularStrategy('message', withTable)).toBe('tables');
|
||||
expect(selectTabularStrategy('message', 'no table here')).toBe('turns');
|
||||
expect(selectTabularStrategy('conversation', withTable)).toBe('turns');
|
||||
expect(extractMarkdownTables(withTable)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,477 @@
|
||||
import { downloadBlob, downloadText } from '../downloadFile';
|
||||
import { Citation } from '../wsFrames';
|
||||
import {
|
||||
buildExportFilename,
|
||||
extractMarkdownTables,
|
||||
parseMarkdownBlocks,
|
||||
stripMarkdown,
|
||||
type MdBlock,
|
||||
} from './markdownUtils';
|
||||
|
||||
export type ExportFormat = 'pdf' | 'docx' | 'csv' | 'xlsx' | 'txt';
|
||||
|
||||
export type ExportTurn = {
|
||||
role: 'user' | 'assistant';
|
||||
message: string;
|
||||
timestamp?: string | Date | null;
|
||||
citations?: Citation[];
|
||||
};
|
||||
|
||||
export type ExportScope = 'message' | 'conversation';
|
||||
|
||||
export type ExportOptions = {
|
||||
format: ExportFormat;
|
||||
scope: ExportScope;
|
||||
title: string;
|
||||
turns: ExportTurn[];
|
||||
};
|
||||
|
||||
function yieldToUi(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function formatTs(value?: string | Date | null): string {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return new Date(value).toISOString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function roleLabel(role: ExportTurn['role']): string {
|
||||
return role === 'user' ? 'User' : 'Assistant';
|
||||
}
|
||||
|
||||
function citationFootnotes(citations?: Citation[]): string {
|
||||
if (!citations?.length) return '';
|
||||
return citations
|
||||
.map((c) => `[${c.index}] ${c.title}${c.url ? ` — ${c.url}` : ''}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export async function exportChat(options: ExportOptions): Promise<void> {
|
||||
const { format, scope, title, turns } = options;
|
||||
const filename = buildExportFilename(title, format === 'docx' ? 'docx' : format);
|
||||
const exportedAt = new Date().toISOString();
|
||||
|
||||
// Yield so large conversations don't freeze the main thread (#97).
|
||||
await yieldToUi();
|
||||
|
||||
switch (format) {
|
||||
case 'txt':
|
||||
downloadText(buildTxt(title, turns, scope), filename);
|
||||
break;
|
||||
case 'csv':
|
||||
await exportCsv(title, turns, scope, filename);
|
||||
break;
|
||||
case 'xlsx':
|
||||
await exportXlsx(title, turns, scope, filename);
|
||||
break;
|
||||
case 'pdf':
|
||||
await exportPdf(title, turns, exportedAt, filename);
|
||||
break;
|
||||
case 'docx':
|
||||
await exportDocx(title, turns, exportedAt, filename);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${format}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTxt(title: string, turns: ExportTurn[], scope: ExportScope): string {
|
||||
if (scope === 'message' && turns.length === 1) {
|
||||
const turn = turns[0];
|
||||
const body = stripMarkdown(turn.message);
|
||||
const notes = citationFootnotes(turn.citations);
|
||||
return notes ? `${body}\n\nSources:\n${notes}` : body;
|
||||
}
|
||||
const parts = [`# ${title}`, ''];
|
||||
for (const turn of turns) {
|
||||
const ts = formatTs(turn.timestamp);
|
||||
parts.push(`[${roleLabel(turn.role)}${ts ? ` · ${ts}` : ''}]`);
|
||||
parts.push(stripMarkdown(turn.message));
|
||||
const notes = citationFootnotes(turn.citations);
|
||||
if (notes) {
|
||||
parts.push('Sources:');
|
||||
parts.push(notes);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function turnsAsRows(turns: ExportTurn[]): string[][] {
|
||||
const header = ['timestamp', 'role', 'message', 'citations'];
|
||||
const rows = turns.map((turn) => [
|
||||
formatTs(turn.timestamp),
|
||||
roleLabel(turn.role),
|
||||
stripMarkdown(turn.message),
|
||||
citationFootnotes(turn.citations).replace(/\n/g, ' | '),
|
||||
]);
|
||||
return [header, ...rows];
|
||||
}
|
||||
|
||||
async function exportCsv(
|
||||
_title: string,
|
||||
turns: ExportTurn[],
|
||||
scope: ExportScope,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const Papa = (await import('papaparse')).default;
|
||||
let matrix: string[][];
|
||||
|
||||
if (scope === 'message' && turns.length === 1) {
|
||||
const tables = extractMarkdownTables(turns[0].message);
|
||||
if (tables.length > 0) {
|
||||
const table = tables[0];
|
||||
matrix = [table.headers, ...table.rows];
|
||||
} else {
|
||||
matrix = turnsAsRows(turns);
|
||||
}
|
||||
} else {
|
||||
matrix = turnsAsRows(turns);
|
||||
}
|
||||
|
||||
const csv = Papa.unparse(matrix);
|
||||
downloadText(csv, filename, 'text/csv;charset=utf-8');
|
||||
}
|
||||
|
||||
async function exportXlsx(
|
||||
_title: string,
|
||||
turns: ExportTurn[],
|
||||
scope: ExportScope,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const XLSX = await import('xlsx');
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
if (scope === 'message' && turns.length === 1) {
|
||||
const tables = extractMarkdownTables(turns[0].message);
|
||||
if (tables.length > 0) {
|
||||
tables.forEach((table, idx) => {
|
||||
const sheet = XLSX.utils.aoa_to_sheet([table.headers, ...table.rows]);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, `Table ${idx + 1}`);
|
||||
});
|
||||
} else {
|
||||
const sheet = XLSX.utils.aoa_to_sheet(turnsAsRows(turns));
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Messages');
|
||||
}
|
||||
} else {
|
||||
const sheet = XLSX.utils.aoa_to_sheet(turnsAsRows(turns));
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, 'Messages');
|
||||
}
|
||||
|
||||
const arrayBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
|
||||
downloadBlob(
|
||||
new Blob([arrayBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
}),
|
||||
filename,
|
||||
);
|
||||
}
|
||||
|
||||
function blocksToPdfContent(blocks: MdBlock[]): unknown[] {
|
||||
const content: unknown[] = [];
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
content.push({
|
||||
text: block.text,
|
||||
style: `h${Math.min(block.level, 3)}`,
|
||||
margin: [0, 8, 0, 4],
|
||||
});
|
||||
break;
|
||||
case 'paragraph':
|
||||
content.push({ text: block.text, margin: [0, 2, 0, 6] });
|
||||
break;
|
||||
case 'code':
|
||||
content.push({
|
||||
text: block.text,
|
||||
fontSize: 9,
|
||||
preserveLeadingSpaces: true,
|
||||
margin: [0, 4, 0, 8],
|
||||
background: '#f5f5f5',
|
||||
});
|
||||
break;
|
||||
case 'list':
|
||||
content.push(
|
||||
block.ordered
|
||||
? { ol: block.items, margin: [0, 2, 0, 6] }
|
||||
: { ul: block.items, margin: [0, 2, 0, 6] },
|
||||
);
|
||||
break;
|
||||
case 'table':
|
||||
content.push({
|
||||
table: {
|
||||
headerRows: 1,
|
||||
widths: block.headers.map(() => '*'),
|
||||
body: [
|
||||
block.headers.map((h) => ({ text: h, bold: true })),
|
||||
...block.rows.map((row) =>
|
||||
block.headers.map((_, col) => row[col] ?? ''),
|
||||
),
|
||||
],
|
||||
},
|
||||
margin: [0, 4, 0, 8],
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function exportPdf(
|
||||
title: string,
|
||||
turns: ExportTurn[],
|
||||
exportedAt: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const pdfMakeModule = await import('pdfmake/build/pdfmake');
|
||||
const pdfFonts = await import('pdfmake/build/vfs_fonts');
|
||||
const pdfMake = pdfMakeModule.default || pdfMakeModule;
|
||||
// vfs_fonts may export vfs on default or as pdfMake.vfs
|
||||
const vfs =
|
||||
(pdfFonts as { pdfMake?: { vfs?: unknown }; default?: { pdfMake?: { vfs?: unknown } } })
|
||||
.pdfMake?.vfs ||
|
||||
(pdfFonts as { default?: { pdfMake?: { vfs?: unknown } } }).default?.pdfMake?.vfs ||
|
||||
(pdfFonts as { default?: unknown }).default;
|
||||
if (vfs) {
|
||||
(pdfMake as { vfs?: unknown }).vfs = vfs;
|
||||
}
|
||||
|
||||
const content: unknown[] = [
|
||||
{ text: title, style: 'title' },
|
||||
{ text: `Exported ${exportedAt}`, style: 'meta', margin: [0, 0, 0, 16] },
|
||||
];
|
||||
|
||||
for (let i = 0; i < turns.length; i += 1) {
|
||||
if (i > 0 && i % 20 === 0) await yieldToUi();
|
||||
const turn = turns[i];
|
||||
if (turns.length > 1) {
|
||||
content.push({
|
||||
text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`,
|
||||
style: 'role',
|
||||
margin: [0, 12, 0, 4],
|
||||
});
|
||||
}
|
||||
content.push(...blocksToPdfContent(parseMarkdownBlocks(turn.message)));
|
||||
if (turn.citations?.length) {
|
||||
content.push({ text: 'Sources', style: 'h3', margin: [0, 8, 0, 4] });
|
||||
content.push({
|
||||
ol: turn.citations
|
||||
.slice()
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((c) => `${c.title}${c.url ? ` (${c.url})` : ''}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const docDefinition = {
|
||||
content,
|
||||
styles: {
|
||||
title: { fontSize: 18, bold: true },
|
||||
meta: { fontSize: 9, color: '#666666' },
|
||||
role: { fontSize: 11, bold: true, color: '#333333' },
|
||||
h1: { fontSize: 16, bold: true },
|
||||
h2: { fontSize: 14, bold: true },
|
||||
h3: { fontSize: 12, bold: true },
|
||||
},
|
||||
defaultStyle: { fontSize: 11 },
|
||||
header: {
|
||||
text: `${title} · ${exportedAt}`,
|
||||
fontSize: 8,
|
||||
color: '#888888',
|
||||
margin: [40, 20, 40, 0] as [number, number, number, number],
|
||||
},
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
// pdfmake typings vary across 0.2/0.3 builds; keep runtime call flexible.
|
||||
const pdf = (pdfMake as { createPdf: (def: unknown) => { getBlob: (cb: (blob: Blob) => void) => void } }).createPdf(
|
||||
docDefinition,
|
||||
);
|
||||
pdf.getBlob((blob: Blob) => {
|
||||
downloadBlob(blob, filename);
|
||||
resolve();
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function exportDocx(
|
||||
title: string,
|
||||
turns: ExportTurn[],
|
||||
exportedAt: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
Header,
|
||||
} = await import('docx');
|
||||
|
||||
const children: InstanceType<typeof Paragraph | typeof Table>[] = [
|
||||
new Paragraph({
|
||||
text: title,
|
||||
heading: HeadingLevel.TITLE,
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `Exported ${exportedAt}`, italics: true, size: 18, color: '666666' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
for (let i = 0; i < turns.length; i += 1) {
|
||||
if (i > 0 && i % 20 === 0) await yieldToUi();
|
||||
const turn = turns[i];
|
||||
if (turns.length > 1) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${roleLabel(turn.role)}${turn.timestamp ? ` · ${formatTs(turn.timestamp)}` : ''}`,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
spacing: { before: 240 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const block of parseMarkdownBlocks(turn.message)) {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
const level =
|
||||
block.level === 1
|
||||
? HeadingLevel.HEADING_1
|
||||
: block.level === 2
|
||||
? HeadingLevel.HEADING_2
|
||||
: HeadingLevel.HEADING_3;
|
||||
children.push(new Paragraph({ text: block.text, heading: level }));
|
||||
break;
|
||||
}
|
||||
case 'paragraph':
|
||||
children.push(new Paragraph({ text: block.text }));
|
||||
break;
|
||||
case 'code':
|
||||
children.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: block.text, font: 'Courier New', size: 18 })],
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'list':
|
||||
block.items.forEach((item, idx) => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: block.ordered ? `${idx + 1}. ${item}` : `• ${item}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'table':
|
||||
children.push(
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: block.headers.map(
|
||||
(h) =>
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: h, bold: true })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
...block.rows.map(
|
||||
(row) =>
|
||||
new TableRow({
|
||||
children: block.headers.map(
|
||||
(_, col) =>
|
||||
new TableCell({
|
||||
children: [new Paragraph({ text: row[col] ?? '' })],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (turn.citations?.length) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: 'Sources',
|
||||
heading: HeadingLevel.HEADING_3,
|
||||
}),
|
||||
);
|
||||
turn.citations
|
||||
.slice()
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.forEach((c) => {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
text: `[${c.index}] ${c.title}${c.url ? ` — ${c.url}` : ''}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const doc = new Document({
|
||||
sections: [
|
||||
{
|
||||
headers: {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${title} · ${exportedAt}`,
|
||||
size: 16,
|
||||
color: '888888',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
/** Decide CSV/XLSX matrix strategy — exported for unit tests. */
|
||||
export function selectTabularStrategy(
|
||||
scope: ExportScope,
|
||||
messageMarkdown: string,
|
||||
): 'tables' | 'turns' {
|
||||
if (scope === 'conversation') return 'turns';
|
||||
return extractMarkdownTables(messageMarkdown).length > 0 ? 'tables' : 'turns';
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export function sanitizeFilenamePart(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'conversation';
|
||||
}
|
||||
|
||||
export function buildExportFilename(
|
||||
conversationTitle: string,
|
||||
extension: string,
|
||||
date = new Date(),
|
||||
): string {
|
||||
const day = date.toISOString().slice(0, 10);
|
||||
const base = sanitizeFilenamePart(conversationTitle || 'conversation');
|
||||
const ext = extension.replace(/^\./, '');
|
||||
return `${base}-${day}.${ext}`;
|
||||
}
|
||||
|
||||
/** Strip markdown to plain text for TXT / tabular message cells. */
|
||||
export function stripMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/```\w*\n?/, '').replace(/```$/, ''),
|
||||
)
|
||||
.replace(/!\[[^\]]*]\([^)]+\)/g, '')
|
||||
.replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*\d+\.\s+/gm, '')
|
||||
.replace(/[*_~`]+/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export type MdBlock =
|
||||
| { type: 'heading'; level: number; text: string }
|
||||
| { type: 'paragraph'; text: string }
|
||||
| { type: 'code'; language: string; text: string }
|
||||
| { type: 'list'; ordered: boolean; items: string[] }
|
||||
| { type: 'table'; headers: string[]; rows: string[][] };
|
||||
|
||||
/** Lightweight markdown → block AST for PDF/DOCX exporters. */
|
||||
export function parseMarkdownBlocks(markdown: string): MdBlock[] {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks: MdBlock[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (/^```/.test(line)) {
|
||||
const language = line.replace(/^```/, '').trim();
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !/^```/.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ type: 'code', language, text: body.join('\n') });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
level: heading[1].length,
|
||||
text: heading[2].trim(),
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\|.+\|$/.test(line.trim()) && i + 1 < lines.length && /^\|?\s*[-:| ]+\|?$/.test(lines[i + 1].trim())) {
|
||||
const headers = splitTableRow(line);
|
||||
i += 2;
|
||||
const rows: string[][] = [];
|
||||
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
|
||||
rows.push(splitTableRow(lines[i]));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ type: 'table', headers, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*+]\s+/.test(line) || /^\s*\d+\.\s+/.test(line)) {
|
||||
const ordered = /^\s*\d+\.\s+/.test(line);
|
||||
const items: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
(ordered ? /^\s*\d+\.\s+/.test(lines[i]) : /^\s*[-*+]\s+/.test(lines[i]))
|
||||
) {
|
||||
items.push(lines[i].replace(/^\s*([-*+]|\d+\.)\s+/, '').trim());
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ type: 'list', ordered, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const para: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() && !/^```/.test(lines[i]) && !/^(#{1,6})\s+/.test(lines[i])) {
|
||||
if (/^\|.+\|$/.test(lines[i].trim())) break;
|
||||
if (/^\s*[-*+]\s+/.test(lines[i]) || /^\s*\d+\.\s+/.test(lines[i])) break;
|
||||
para.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ type: 'paragraph', text: para.join(' ').trim() });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
return line
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
/** Extract markdown tables from a single message (for CSV/XLSX tabular rules). */
|
||||
export function extractMarkdownTables(markdown: string): { headers: string[]; rows: string[][] }[] {
|
||||
return parseMarkdownBlocks(markdown)
|
||||
.filter((b): b is Extract<MdBlock, { type: 'table' }> => b.type === 'table')
|
||||
.map(({ headers, rows }) => ({ headers, rows }));
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
formatMoneyCents,
|
||||
formatTokenCount,
|
||||
humanizeStatus,
|
||||
invoiceProviderLabel,
|
||||
isComplimentarySubscription,
|
||||
isStoreSubscription,
|
||||
isStripeSubscription,
|
||||
pickPrimaryInvoice,
|
||||
planAllowsRag,
|
||||
} from './finance';
|
||||
@@ -92,9 +96,44 @@ describe('finance helpers', () => {
|
||||
expect(pickPrimaryInvoice(invoices)?.id).toBe(2);
|
||||
});
|
||||
|
||||
it('detects portal access from paid or subscribed invoices', () => {
|
||||
it('detects portal access from paid Stripe invoices only', () => {
|
||||
expect(canOpenBillingPortal([baseInvoice({ status: 'open' })])).toBe(false);
|
||||
expect(canOpenBillingPortal([baseInvoice({ status: 'paid' })])).toBe(true);
|
||||
expect(
|
||||
canOpenBillingPortal([
|
||||
baseInvoice({
|
||||
status: 'paid',
|
||||
provider: 'revenuecat',
|
||||
stripe_subscription_id: null,
|
||||
revenuecat_store: 'PLAY_STORE',
|
||||
}),
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('labels invoice providers for history', () => {
|
||||
expect(invoiceProviderLabel(baseInvoice({ provider: 'stripe' }))).toBe('Stripe');
|
||||
expect(
|
||||
invoiceProviderLabel(
|
||||
baseInvoice({ provider: 'revenuecat', revenuecat_store: 'PLAY_STORE' })
|
||||
)
|
||||
).toBe('Play Store');
|
||||
expect(
|
||||
invoiceProviderLabel(
|
||||
baseInvoice({ provider: 'revenuecat', revenuecat_store: 'APP_STORE' })
|
||||
)
|
||||
).toBe('App Store');
|
||||
});
|
||||
|
||||
it('classifies subscription sources', () => {
|
||||
expect(isStoreSubscription('revenuecat')).toBe(true);
|
||||
expect(isStripeSubscription('stripe')).toBe(true);
|
||||
expect(
|
||||
isComplimentarySubscription(
|
||||
{ source: 'revenuecat', needs_checkout: false },
|
||||
false
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ export type FinanceInvoice = {
|
||||
stripe_invoice_id: string | null;
|
||||
stripe_checkout_session_id: string | null;
|
||||
stripe_subscription_id: string | null;
|
||||
revenuecat_event_id?: string | null;
|
||||
revenuecat_store?: string | null;
|
||||
hosted_invoice_url: string;
|
||||
description: string;
|
||||
created: string;
|
||||
@@ -25,6 +27,7 @@ export type FinancePayment = {
|
||||
amount: number;
|
||||
stripe_payment_intent_id: string | null;
|
||||
stripe_charge_id: string | null;
|
||||
revenuecat_transaction_id?: string | null;
|
||||
paid_at: string | null;
|
||||
failure_message: string;
|
||||
created: string;
|
||||
@@ -75,12 +78,61 @@ export type SubscriptionMe = {
|
||||
source: string;
|
||||
needs_checkout: boolean;
|
||||
stripe_subscription_id: string;
|
||||
revenuecat_original_transaction_id?: string;
|
||||
cancel_at_period_end?: boolean;
|
||||
current_period_end?: string | null;
|
||||
usage: SubscriptionUsage;
|
||||
};
|
||||
|
||||
export type SubscriptionSource = 'none' | 'stripe' | 'backer' | 'admin' | string;
|
||||
export type SubscriptionSource =
|
||||
| 'none'
|
||||
| 'stripe'
|
||||
| 'revenuecat'
|
||||
| 'backer'
|
||||
| 'admin'
|
||||
| string;
|
||||
|
||||
export function isStoreSubscription(
|
||||
source: string | null | undefined
|
||||
): boolean {
|
||||
return source === 'revenuecat';
|
||||
}
|
||||
|
||||
export function isStripeSubscription(
|
||||
source: string | null | undefined
|
||||
): boolean {
|
||||
return source === 'stripe';
|
||||
}
|
||||
|
||||
/** Human label for invoice provider / store (history badge). */
|
||||
export function invoiceProviderLabel(
|
||||
invoice: Pick<FinanceInvoice, 'provider' | 'revenuecat_store'>
|
||||
): string {
|
||||
const provider = (invoice.provider || '').toLowerCase();
|
||||
if (provider === 'revenuecat') {
|
||||
const store = (invoice.revenuecat_store || '').toUpperCase();
|
||||
if (
|
||||
store.includes('PLAY') ||
|
||||
store === 'GOOGLE' ||
|
||||
store === 'GOOGLE_PLAY' ||
|
||||
store === 'PLAY_STORE'
|
||||
) {
|
||||
return 'Play Store';
|
||||
}
|
||||
if (
|
||||
store.includes('APP_STORE') ||
|
||||
store.includes('MAC') ||
|
||||
store === 'APPLE' ||
|
||||
store === 'APP_STORE'
|
||||
) {
|
||||
return 'App Store';
|
||||
}
|
||||
if (store) return store;
|
||||
return 'Store';
|
||||
}
|
||||
if (provider === 'stripe') return 'Stripe';
|
||||
return invoice.provider || '—';
|
||||
}
|
||||
|
||||
/** Complimentary / admin-granted access — no Stripe cancel/change. */
|
||||
export function isComplimentarySubscription(
|
||||
@@ -89,6 +141,13 @@ export function isComplimentarySubscription(
|
||||
): boolean {
|
||||
if (!subscription) return false;
|
||||
if (subscription.source === 'backer' || subscription.source === 'admin') return true;
|
||||
// Paid Stripe / store entitlements are not complimentary even without portal rows.
|
||||
if (
|
||||
isStripeSubscription(subscription.source) ||
|
||||
isStoreSubscription(subscription.source)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !hasPortalAccess && subscription.needs_checkout === false;
|
||||
}
|
||||
|
||||
@@ -171,9 +230,12 @@ export function pickPrimaryInvoice(invoices: FinanceInvoice[]): FinanceInvoice |
|
||||
return invoices[0];
|
||||
}
|
||||
|
||||
/** Stripe customer portal only — ignore RevenueCat / store invoices. */
|
||||
export function canOpenBillingPortal(invoices: FinanceInvoice[]): boolean {
|
||||
return invoices.some(
|
||||
(inv) => Boolean(inv.stripe_subscription_id) || inv.status === 'paid'
|
||||
(inv) =>
|
||||
(inv.provider || 'stripe').toLowerCase() === 'stripe' &&
|
||||
(Boolean(inv.stripe_subscription_id) || inv.status === 'paid')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { upsertPromptFeedback, clearPromptFeedback } from './promptFeedback';
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
|
||||
jest.mock('../../axiosApi', () => ({
|
||||
axiosInstance: {
|
||||
post: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('promptFeedback optimistic helpers', () => {
|
||||
const post = axiosInstance.post as jest.Mock;
|
||||
const del = axiosInstance.delete as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset();
|
||||
del.mockReset();
|
||||
});
|
||||
|
||||
it('posts upsert payload', async () => {
|
||||
post.mockResolvedValue({ data: { rating: 'up' } });
|
||||
await upsertPromptFeedback(42, { rating: 'up' });
|
||||
expect(post).toHaveBeenCalledWith('prompt_feedback', {
|
||||
prompt_id: 42,
|
||||
rating: 'up',
|
||||
reason: undefined,
|
||||
comment: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes vote by prompt_id', async () => {
|
||||
del.mockResolvedValue({});
|
||||
await clearPromptFeedback(42);
|
||||
expect(del).toHaveBeenCalledWith('prompt_feedback', {
|
||||
params: { prompt_id: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces request failures for rollback callers', async () => {
|
||||
post.mockRejectedValue(new Error('network'));
|
||||
await expect(upsertPromptFeedback(1, { rating: 'down' })).rejects.toThrow(
|
||||
'network',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { axiosInstance } from '../../axiosApi';
|
||||
|
||||
export type PromptRating = 'up' | 'down';
|
||||
|
||||
export type PromptFeedbackPayload = {
|
||||
rating: PromptRating;
|
||||
reason?: string | null;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export type PromptFeedbackResponse = PromptFeedbackPayload & {
|
||||
id?: number;
|
||||
prompt_id?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upsert thumbs rating for an assistant prompt (chat_backend#67).
|
||||
*/
|
||||
export async function upsertPromptFeedback(
|
||||
promptId: number,
|
||||
payload: PromptFeedbackPayload,
|
||||
): Promise<PromptFeedbackResponse> {
|
||||
const { data } = await axiosInstance.post<PromptFeedbackResponse>('prompt_feedback', {
|
||||
prompt_id: promptId,
|
||||
rating: payload.rating,
|
||||
reason: payload.reason ?? undefined,
|
||||
comment: payload.comment ?? undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a vote for an assistant prompt.
|
||||
*/
|
||||
export async function clearPromptFeedback(promptId: number): Promise<void> {
|
||||
await axiosInstance.delete('prompt_feedback', {
|
||||
params: { prompt_id: promptId },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
jest.mock('@revenuecat/purchases-capacitor', () => ({
|
||||
Purchases: {
|
||||
configure: jest.fn(),
|
||||
logIn: jest.fn(),
|
||||
logOut: jest.fn(),
|
||||
getOfferings: jest.fn(),
|
||||
purchasePackage: jest.fn(),
|
||||
restorePurchases: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveAppUserId,
|
||||
isPurchaseCancelledError,
|
||||
} from './revenueCat';
|
||||
import { ACCESS_TOKEN_KEY } from '../auth/tokenStorage';
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const body = btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `hdr.${body}.sig`;
|
||||
}
|
||||
|
||||
describe('revenueCat helpers', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('prefers account.id for appUserID', () => {
|
||||
expect(resolveAppUserId({ id: 42 })).toBe('42');
|
||||
});
|
||||
|
||||
it('falls back to JWT user_id claim', () => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, makeJwt({ user_id: 99, exp: 9999999999 }));
|
||||
expect(resolveAppUserId(null)).toBe('99');
|
||||
});
|
||||
|
||||
it('returns null when no id available', () => {
|
||||
expect(resolveAppUserId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('detects cancelled purchase errors', () => {
|
||||
expect(isPurchaseCancelledError({ userCancelled: true })).toBe(true);
|
||||
expect(isPurchaseCancelledError({ code: 1 })).toBe(true);
|
||||
expect(isPurchaseCancelledError({ message: 'boom' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* RevenueCat Capacitor IAP wrapper (#100).
|
||||
* No-ops on web; only configures / purchases on native platforms.
|
||||
*/
|
||||
import { Purchases } from '@revenuecat/purchases-capacitor';
|
||||
import type { PurchasesPackage } from '@revenuecat/purchases-capacitor';
|
||||
import { decodeJwtPayload } from '../auth/jwtHelpers';
|
||||
import { getAccessToken } from '../auth/tokenStorage';
|
||||
import { isNativePlatform } from '../platform/nativePlatform';
|
||||
|
||||
let configurePromise: Promise<void> | null = null;
|
||||
let configured = false;
|
||||
|
||||
type AccountLike = { id?: number | string | null };
|
||||
|
||||
function getCapacitorPlatform(): string {
|
||||
if (typeof window === 'undefined') return 'web';
|
||||
const Cap = window.Capacitor as
|
||||
| { getPlatform?: () => string; isNativePlatform?: () => boolean }
|
||||
| undefined;
|
||||
if (typeof Cap?.getPlatform === 'function') {
|
||||
return Cap.getPlatform();
|
||||
}
|
||||
return 'web';
|
||||
}
|
||||
|
||||
function revenueCatApiKey(): string {
|
||||
const platform = getCapacitorPlatform();
|
||||
if (platform === 'ios') {
|
||||
return (process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim();
|
||||
}
|
||||
if (platform === 'android') {
|
||||
return (process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim();
|
||||
}
|
||||
// Fallback: prefer Google then Apple if platform unknown on native.
|
||||
return (
|
||||
(process.env.REACT_APP_REVENUECAT_GOOGLE_API_KEY || '').trim() ||
|
||||
(process.env.REACT_APP_REVENUECAT_APPLE_API_KEY || '').trim()
|
||||
);
|
||||
}
|
||||
|
||||
/** Prefer numeric user pk; fall back to JWT `user_id` claim. */
|
||||
export function resolveAppUserId(account?: AccountLike | null): string | null {
|
||||
if (account?.id != null && String(account.id).trim() !== '') {
|
||||
return String(account.id);
|
||||
}
|
||||
const token = getAccessToken();
|
||||
if (!token) return null;
|
||||
const payload = decodeJwtPayload(token) as { user_id?: number | string } | null;
|
||||
if (payload?.user_id != null && String(payload.user_id).trim() !== '') {
|
||||
return String(payload.user_id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function configureRevenueCat(): Promise<boolean> {
|
||||
if (!isNativePlatform()) return false;
|
||||
if (configured) return true;
|
||||
if (configurePromise) {
|
||||
await configurePromise;
|
||||
return configured;
|
||||
}
|
||||
|
||||
configurePromise = (async () => {
|
||||
const apiKey = revenueCatApiKey();
|
||||
if (!apiKey) {
|
||||
console.warn(
|
||||
'RevenueCat API key missing (REACT_APP_REVENUECAT_APPLE_API_KEY / GOOGLE).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
await Purchases.configure({ apiKey });
|
||||
configured = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await configurePromise;
|
||||
} finally {
|
||||
configurePromise = null;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
export async function logInRevenueCat(
|
||||
appUserIdOrAccount?: string | AccountLike | null
|
||||
): Promise<void> {
|
||||
if (!isNativePlatform()) return;
|
||||
const appUserID =
|
||||
typeof appUserIdOrAccount === 'string'
|
||||
? appUserIdOrAccount
|
||||
: resolveAppUserId(appUserIdOrAccount);
|
||||
if (!appUserID) {
|
||||
console.warn('RevenueCat logIn skipped: no app user id');
|
||||
return;
|
||||
}
|
||||
const ready = await configureRevenueCat();
|
||||
if (!ready) return;
|
||||
await Purchases.logIn({ appUserID });
|
||||
}
|
||||
|
||||
export async function logOutRevenueCat(): Promise<void> {
|
||||
if (!isNativePlatform()) return;
|
||||
if (!configured) return;
|
||||
try {
|
||||
await Purchases.logOut();
|
||||
} catch (error) {
|
||||
// Anonymous / already logged out — ignore.
|
||||
console.warn('RevenueCat logOut', error);
|
||||
}
|
||||
}
|
||||
|
||||
function packageMatchesPlan(pkg: PurchasesPackage, planSlug: string): boolean {
|
||||
const slug = planSlug.toLowerCase();
|
||||
const id = (pkg.identifier || '').toLowerCase();
|
||||
const productId = (pkg.product?.identifier || '').toLowerCase();
|
||||
return id === slug || id.includes(slug) || productId.includes(slug);
|
||||
}
|
||||
|
||||
async function resolvePackageForPlan(
|
||||
planSlug?: string
|
||||
): Promise<PurchasesPackage> {
|
||||
const offerings = await Purchases.getOfferings();
|
||||
const offeringId = (process.env.REACT_APP_REVENUECAT_OFFERING_ID || '').trim();
|
||||
const offering =
|
||||
(offeringId && offerings.all?.[offeringId]) || offerings.current || null;
|
||||
if (!offering) {
|
||||
throw new Error('No RevenueCat offerings available. Check the RC dashboard.');
|
||||
}
|
||||
|
||||
const packages = offering.availablePackages || [];
|
||||
if (!packages.length) {
|
||||
throw new Error('RevenueCat offering has no packages.');
|
||||
}
|
||||
|
||||
if (planSlug) {
|
||||
const match = packages.find((pkg) => packageMatchesPlan(pkg, planSlug));
|
||||
if (match) return match;
|
||||
throw new Error(`No store package matches plan "${planSlug}".`);
|
||||
}
|
||||
|
||||
return (
|
||||
offering.monthly ||
|
||||
packages.find((pkg) => (pkg.identifier || '').includes('monthly')) ||
|
||||
packages[0]
|
||||
);
|
||||
}
|
||||
|
||||
export async function purchasePlan(planSlug?: string): Promise<void> {
|
||||
if (!isNativePlatform()) {
|
||||
throw new Error('Store purchases are only available in the mobile app.');
|
||||
}
|
||||
const ready = await configureRevenueCat();
|
||||
if (!ready) {
|
||||
throw new Error('RevenueCat is not configured. Missing API key.');
|
||||
}
|
||||
const aPackage = await resolvePackageForPlan(planSlug);
|
||||
await Purchases.purchasePackage({ aPackage });
|
||||
}
|
||||
|
||||
export async function restorePurchases(): Promise<void> {
|
||||
if (!isNativePlatform()) {
|
||||
throw new Error('Restore is only available in the mobile app.');
|
||||
}
|
||||
const ready = await configureRevenueCat();
|
||||
if (!ready) {
|
||||
throw new Error('RevenueCat is not configured. Missing API key.');
|
||||
}
|
||||
await Purchases.restorePurchases();
|
||||
}
|
||||
|
||||
export function isPurchaseCancelledError(error: unknown): boolean {
|
||||
const err = error as { userCancelled?: boolean; code?: number | string };
|
||||
if (err?.userCancelled === true) return true;
|
||||
// PURCHASES_ERROR_CODE.PURCHASE_CANCELLED_ERROR === 1
|
||||
return err?.code === 1 || err?.code === '1' || err?.code === 'PURCHASE_CANCELLED';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
type ToastListener = (message: string, severity: 'error' | 'success' | 'info') => void;
|
||||
|
||||
const listeners = new Set<ToastListener>();
|
||||
|
||||
export function subscribeToast(listener: ToastListener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function showToast(
|
||||
message: string,
|
||||
severity: 'error' | 'success' | 'info' = 'info',
|
||||
): void {
|
||||
listeners.forEach((listener) => listener(message, severity));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
parseCitationsData,
|
||||
parseVersionedFrame,
|
||||
} from './wsFrames';
|
||||
|
||||
describe('wsFrames', () => {
|
||||
it('parses a citations frame', () => {
|
||||
const raw = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'citations',
|
||||
data: [
|
||||
{
|
||||
index: 1,
|
||||
title: 'Example',
|
||||
url: 'https://example.com',
|
||||
published_at: '2026-07-03',
|
||||
},
|
||||
],
|
||||
});
|
||||
const frame = parseVersionedFrame(raw);
|
||||
expect(frame).toEqual({
|
||||
v: 1,
|
||||
type: 'citations',
|
||||
data: [
|
||||
{
|
||||
index: 1,
|
||||
title: 'Example',
|
||||
url: 'https://example.com',
|
||||
published_at: '2026-07-03',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parseCitationsData(frame!.data)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores unknown types without throwing', () => {
|
||||
const frame = parseVersionedFrame(
|
||||
JSON.stringify({ v: 1, type: 'status', data: { stage: 'search' } }),
|
||||
);
|
||||
expect(frame?.type).toBe('status');
|
||||
});
|
||||
|
||||
it('returns null for sentinels and plain text', () => {
|
||||
expect(parseVersionedFrame('END_OF_THE_STREAM_ENDER_GAME_42')).toBeNull();
|
||||
expect(parseVersionedFrame('hello world')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty citations for bad data', () => {
|
||||
expect(parseCitationsData(null)).toEqual([]);
|
||||
expect(parseCitationsData('nope')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Versioned WebSocket frame envelopes shared by citations (#98) and status (#96).
|
||||
* Shape: { v: 1, type: string, data: unknown }
|
||||
*/
|
||||
|
||||
export type VersionedFrame = {
|
||||
v: number;
|
||||
type: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
export type Citation = {
|
||||
index: number;
|
||||
title: string;
|
||||
url: string;
|
||||
published_at?: string | null;
|
||||
};
|
||||
|
||||
export function parseVersionedFrame(raw: string): VersionedFrame | null {
|
||||
const trimmed = raw?.trim?.() ?? '';
|
||||
if (!trimmed.startsWith('{')) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
typeof parsed.v === 'number' &&
|
||||
typeof parsed.type === 'string'
|
||||
) {
|
||||
return {
|
||||
v: parsed.v,
|
||||
type: parsed.type,
|
||||
data: parsed.data,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to sentinel / stream text handling
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseCitationsData(data: unknown): Citation[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
!!item && typeof item === 'object' && typeof (item as { index?: unknown }).index === 'number',
|
||||
)
|
||||
.map((item) => ({
|
||||
index: item.index as number,
|
||||
title: typeof item.title === 'string' ? item.title : `Source ${item.index}`,
|
||||
url: typeof item.url === 'string' ? item.url : '',
|
||||
published_at:
|
||||
typeof item.published_at === 'string' ? item.published_at : null,
|
||||
}));
|
||||
}
|
||||
Vendored
+1
@@ -9,6 +9,7 @@ interface CapacitorPreferencesPlugin {
|
||||
interface CapacitorBridge {
|
||||
isNativePlatform?: () => boolean;
|
||||
isNative?: boolean;
|
||||
getPlatform?: () => string;
|
||||
Plugins?: {
|
||||
Preferences?: CapacitorPreferencesPlugin;
|
||||
App?: { addListener?: (...args: unknown[]) => unknown };
|
||||
|
||||
Reference in New Issue
Block a user