cleaned up stripe config
This commit is contained in:
+159
-52
@@ -17,7 +17,7 @@ type Bird = {
|
||||
id: string;
|
||||
workspaceId?: number;
|
||||
name: string;
|
||||
tagId: string;
|
||||
tagId: string | null;
|
||||
species: string;
|
||||
gender: BirdGender;
|
||||
dateOfBirth: string | null;
|
||||
@@ -231,6 +231,11 @@ type AuthNotice = {
|
||||
previewUrl?: string | null;
|
||||
};
|
||||
|
||||
type BillingNotice = {
|
||||
kind: 'success' | 'info' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type BulkWeightRowState = {
|
||||
weightGrams: string;
|
||||
};
|
||||
@@ -310,7 +315,7 @@ const emptyBirdForm: BirdFormState = {
|
||||
const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({
|
||||
memorializedOn: new Date().toISOString().slice(0, 10),
|
||||
memorialNote: '',
|
||||
notifyOnMemorialDay: true,
|
||||
notifyOnMemorialDay: false,
|
||||
});
|
||||
|
||||
const emptyWorkspaceForm: WorkspaceFormState = {
|
||||
@@ -408,7 +413,7 @@ const sortBirdsByName = (nextBirds: Bird[]) => [...nextBirds].sort((left, right)
|
||||
|
||||
const toBirdForm = (bird: Bird): BirdFormState => ({
|
||||
name: bird.name,
|
||||
tagId: bird.tagId,
|
||||
tagId: bird.tagId ?? '',
|
||||
species: bird.species,
|
||||
gender: bird.gender,
|
||||
dateOfBirth: bird.dateOfBirth ?? '',
|
||||
@@ -1072,6 +1077,7 @@ function App() {
|
||||
const [authProviders, setAuthProviders] = useState<AuthProvider[]>([]);
|
||||
const [authForm, setAuthForm] = useState<AuthFormState>(emptyAuthForm);
|
||||
const [authNotice, setAuthNotice] = useState<AuthNotice | null>(null);
|
||||
const [billingNotice, setBillingNotice] = useState<BillingNotice | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [authSubmitting, setAuthSubmitting] = useState(false);
|
||||
const [lostBirdReportForm, setLostBirdReportForm] = useState<LostBirdReportFormState>(emptyLostBirdReportForm);
|
||||
@@ -1157,6 +1163,7 @@ function App() {
|
||||
} | null>(null);
|
||||
const [deletingBird, setDeletingBird] = useState(false);
|
||||
const [memorializingBird, setMemorializingBird] = useState(false);
|
||||
const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState('');
|
||||
const [editingVetVisitId, setEditingVetVisitId] = useState('');
|
||||
const [deletingVetVisitId, setDeletingVetVisitId] = useState('');
|
||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||
@@ -1572,6 +1579,7 @@ function App() {
|
||||
setAuthSession(session);
|
||||
setAuthProviders(session.providers);
|
||||
setAuthNotice(null);
|
||||
setBillingNotice(null);
|
||||
setNewIntegrationTokenSecret('');
|
||||
setWorkspace(session.activeWorkspace);
|
||||
setActiveMembership({
|
||||
@@ -1616,6 +1624,34 @@ function App() {
|
||||
setIntegrationTokenForm(emptyIntegrationTokenForm);
|
||||
setNewIntegrationTokenSecret('');
|
||||
setAuthNotice(null);
|
||||
setBillingNotice(null);
|
||||
};
|
||||
|
||||
const refreshAuthSession = async (token: string) => {
|
||||
const response = await apiFetch('/auth/session', token);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearAppSession();
|
||||
}
|
||||
|
||||
throw new Error(await readErrorMessage(response, 'Unable to refresh your billing status.'));
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
||||
|
||||
if (!data.session) {
|
||||
throw new Error('Unable to refresh your billing status.');
|
||||
}
|
||||
|
||||
const nextToken = data.token || token;
|
||||
persistSessionToken(nextToken);
|
||||
applySession(data.session, nextToken);
|
||||
|
||||
return {
|
||||
session: data.session,
|
||||
token: nextToken,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1646,30 +1682,61 @@ function App() {
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const callbackToken = url.searchParams.get('auth_token') ?? '';
|
||||
const billingState = url.searchParams.get('billing');
|
||||
const token = callbackToken || readStoredSessionToken();
|
||||
|
||||
if (callbackToken) {
|
||||
persistSessionToken(callbackToken);
|
||||
url.searchParams.delete('auth_token');
|
||||
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await apiFetch('/auth/session', token);
|
||||
const { session, token: sessionToken } = await refreshAuthSession(token);
|
||||
|
||||
if (!response.ok) {
|
||||
clearAppSession();
|
||||
return;
|
||||
if (billingState === 'success' || billingState === 'portal') {
|
||||
try {
|
||||
const syncResponse = await apiFetch('/billing/sync', sessionToken, { method: 'POST' });
|
||||
|
||||
if (!syncResponse.ok) {
|
||||
throw new Error(await readErrorMessage(syncResponse, 'Returned from Stripe, but billing could not be refreshed yet.'));
|
||||
}
|
||||
|
||||
const { session: refreshedSession } = await refreshAuthSession(sessionToken);
|
||||
const syncedWorkspace = refreshedSession.activeWorkspace;
|
||||
const planName = formatBillingPlanName(syncedWorkspace.billingPlan);
|
||||
const intervalName = formatBillingIntervalName(syncedWorkspace.billingInterval);
|
||||
|
||||
setBillingNotice({
|
||||
kind: 'success',
|
||||
message:
|
||||
billingState === 'success'
|
||||
? `Stripe checkout completed. Billing is now ${planName} on ${intervalName}.`
|
||||
: `Stripe billing changes synced. Current plan: ${planName} on ${intervalName}.`,
|
||||
});
|
||||
} catch (billingSyncError) {
|
||||
setBillingNotice({
|
||||
kind: 'info',
|
||||
message:
|
||||
billingSyncError instanceof Error
|
||||
? billingSyncError.message
|
||||
: 'Returned from Stripe. Billing changes may still be syncing.',
|
||||
});
|
||||
}
|
||||
} else if (billingState === 'cancelled') {
|
||||
setBillingNotice({
|
||||
kind: 'info',
|
||||
message: 'Stripe checkout was cancelled. No billing changes were applied.',
|
||||
});
|
||||
} else {
|
||||
setBillingNotice(null);
|
||||
}
|
||||
|
||||
const data = (await readJsonSafely<{ token?: string; session?: AuthSessionPayload }>(response)) ?? {};
|
||||
|
||||
if (data.session && (data.token || token)) {
|
||||
persistSessionToken(data.token || token);
|
||||
applySession(data.session, data.token || token);
|
||||
if (session && (callbackToken || billingState)) {
|
||||
url.searchParams.delete('billing');
|
||||
window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`);
|
||||
setError('');
|
||||
}
|
||||
} catch (loadError) {
|
||||
@@ -2931,6 +2998,38 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemorialReminderPreferenceChange = async (bird: Bird, notifyOnMemorialDay: boolean) => {
|
||||
if (savingMemorialReminderBirdId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingMemorialReminderBirdId(bird.id);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/birds/${bird.id}/memorial-reminders`, authToken, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notifyOnMemorialDay }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, 'Unable to update memorial reminder setting.'));
|
||||
}
|
||||
|
||||
const data = await readJsonSafely<{ bird: Bird }>(response);
|
||||
if (!data?.bird) {
|
||||
throw new Error('Unable to update memorial reminder setting.');
|
||||
}
|
||||
|
||||
setMemorializedBirds((current) => current.map((currentBird) => (currentBird.id === data.bird.id ? data.bird : currentBird)));
|
||||
} catch (preferenceError) {
|
||||
setError(preferenceError instanceof Error ? preferenceError.message : 'Unable to update memorial reminder setting.');
|
||||
} finally {
|
||||
setSavingMemorialReminderBirdId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFlockTransferSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (transferringBird) {
|
||||
@@ -3172,6 +3271,7 @@ function App() {
|
||||
}
|
||||
|
||||
setError('');
|
||||
setBillingNotice(null);
|
||||
setBillingRedirecting(true);
|
||||
setSavingWorkspace(true);
|
||||
|
||||
@@ -3210,6 +3310,7 @@ function App() {
|
||||
}
|
||||
|
||||
setError('');
|
||||
setBillingNotice(null);
|
||||
setBillingRedirecting(true);
|
||||
|
||||
try {
|
||||
@@ -4104,7 +4205,7 @@ function App() {
|
||||
</span>
|
||||
</h3>
|
||||
<p className="muted">
|
||||
{selectedBird.species} • Band {selectedBird.tagId}
|
||||
{selectedBird.species} • {selectedBird.tagId ? `Band ${selectedBird.tagId}` : 'Band ID not recorded'}
|
||||
</p>
|
||||
<p className="muted">Added {formatDate(selectedBird.createdAt.slice(0, 10))}</p>
|
||||
</div>
|
||||
@@ -4117,7 +4218,7 @@ function App() {
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Band ID</span>
|
||||
<strong>{selectedBird.tagId}</strong>
|
||||
<strong>{selectedBird.tagId || 'Not recorded'}</strong>
|
||||
</article>
|
||||
<article className="detail-card">
|
||||
<span>Hatch Day</span>
|
||||
@@ -4489,6 +4590,7 @@ function App() {
|
||||
</div>
|
||||
{workspace?.workspaceType !== 'rescue' ? (
|
||||
<div className="form-panel">
|
||||
{billingNotice ? <p className={billingNotice.kind === 'error' ? 'error-banner' : 'success-banner'}>{billingNotice.message}</p> : null}
|
||||
<label>
|
||||
Household plan
|
||||
<select
|
||||
@@ -4964,32 +5066,6 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{memorializedBirds.length ? (
|
||||
<section className="settings-subsection settings-nested-card">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Memorials</p>
|
||||
<h3>Memorialized birds</h3>
|
||||
<p className="muted">
|
||||
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-list">
|
||||
{memorializedBirds.map((bird) => (
|
||||
<article className="vet-visit-card" key={bird.id}>
|
||||
<strong>{bird.name}</strong>
|
||||
<small>
|
||||
{bird.species} • Memorialized {formatDate(bird.memorializedOn)}
|
||||
</small>
|
||||
{bird.notifyOnMemorialDay ? <small>Memorial day reminders enabled.</small> : <small>Memorial day reminders off.</small>}
|
||||
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<form className="form-panel settings-nested-stack" onSubmit={handleBirdSubmit}>
|
||||
<section className="settings-nested-card">
|
||||
<div className="settings-nested-header">
|
||||
@@ -5003,7 +5079,11 @@ function App() {
|
||||
</label>
|
||||
<label>
|
||||
Band ID
|
||||
<input value={birdForm.tagId} onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })} required />
|
||||
<input
|
||||
value={birdForm.tagId}
|
||||
onChange={(event) => setBirdForm({ ...birdForm, tagId: event.target.value })}
|
||||
placeholder="Optional if unknown"
|
||||
/>
|
||||
</label>
|
||||
<label className="species-picker-field wide-field">
|
||||
Species
|
||||
@@ -5398,15 +5478,6 @@ function App() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="toggle-card">
|
||||
<span>Send memorial day reminders</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={memorializeBirdForm.notifyOnMemorialDay}
|
||||
onChange={(event) => setMemorializeBirdForm({ ...memorializeBirdForm, notifyOnMemorialDay: event.target.checked })}
|
||||
/>
|
||||
<small className="muted">Send an annual remembrance notification using the same delivery workflow as Hatch Day reminders.</small>
|
||||
</label>
|
||||
<label className="wide-field">
|
||||
Memorial note
|
||||
<textarea
|
||||
@@ -5433,6 +5504,42 @@ function App() {
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{memorializedBirds.length ? (
|
||||
<section className="settings-subsection settings-nested-card">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<p className="eyebrow">Memorials</p>
|
||||
<h3>Memorialized birds</h3>
|
||||
<p className="muted">
|
||||
These profiles are read-only, hidden from the standard flock view, and excluded from household plan bird counts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-list">
|
||||
{memorializedBirds.map((bird) => (
|
||||
<article className="vet-visit-card" key={bird.id}>
|
||||
<strong>{bird.name}</strong>
|
||||
<small>
|
||||
{bird.species} • Memorialized {formatDate(bird.memorializedOn)}
|
||||
</small>
|
||||
{bird.memorialNote ? <p className="muted">{bird.memorialNote}</p> : null}
|
||||
<label className="toggle-card">
|
||||
<span>Send memorial reminders</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bird.notifyOnMemorialDay}
|
||||
disabled={savingMemorialReminderBirdId === bird.id || activeMembership?.role !== 'owner'}
|
||||
onChange={(event) => handleMemorialReminderPreferenceChange(bird, event.target.checked)}
|
||||
/>
|
||||
<small className="muted">Send an annual rememberance notificaiton.</small>
|
||||
</label>
|
||||
{activeMembership?.role !== 'owner' ? <small>Only flock owners can change memorial reminder settings.</small> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
@@ -5475,7 +5582,7 @@ function App() {
|
||||
<option value="">Select a bird from this flock</option>
|
||||
{birds.map((bird) => (
|
||||
<option key={bird.id} value={bird.id}>
|
||||
{bird.name} • {bird.species} • Band {bird.tagId}
|
||||
{bird.name} • {bird.species} • {bird.tagId ? `Band ${bird.tagId}` : 'Band ID not recorded'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user