update from user testing payment platform
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { handleResult } from '$lib/forms';
|
||||
import { notices } from '$lib/stores/notices.svelte';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button, Accordion, AccordionItem, NoticeDialog } from '$lib/components';
|
||||
import {
|
||||
ViewHeader,
|
||||
CardGrid,
|
||||
Card,
|
||||
Button,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
NoticeDialog
|
||||
} from '$lib/components';
|
||||
import { COMMON_TIMEZONES } from '@shared/timezone';
|
||||
import { handleOf } from '@shared/slugify';
|
||||
import { addMonthsUTC, formatShortDate } from '$lib/format';
|
||||
@@ -37,9 +47,23 @@
|
||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1);
|
||||
let paydayTime = $state(fam?.paydayTime || '18:00');
|
||||
let paydayTimes = $state([
|
||||
'06:00', '07:00', '08:00', '09:00', '10:00', '11:00', '12:00',
|
||||
'13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00',
|
||||
'20:00', '21:00', '22:00'
|
||||
'06:00',
|
||||
'07:00',
|
||||
'08:00',
|
||||
'09:00',
|
||||
'10:00',
|
||||
'11:00',
|
||||
'12:00',
|
||||
'13:00',
|
||||
'14:00',
|
||||
'15:00',
|
||||
'16:00',
|
||||
'17:00',
|
||||
'18:00',
|
||||
'19:00',
|
||||
'20:00',
|
||||
'21:00',
|
||||
'22:00'
|
||||
]);
|
||||
let timezone = $state(fam?.timezone || 'auto');
|
||||
let timezoneOptions = $state(COMMON_TIMEZONES);
|
||||
@@ -48,7 +72,9 @@
|
||||
if (typeof Intl !== 'undefined') {
|
||||
try {
|
||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch { detectedTz = ''; }
|
||||
} catch {
|
||||
detectedTz = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
let showQR = $state(false);
|
||||
@@ -83,6 +109,16 @@
|
||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail);
|
||||
}
|
||||
|
||||
function timeUntil(iso: string): string {
|
||||
const diffMs = new Date(iso).getTime() - Date.now();
|
||||
if (diffMs <= 0) return 'expired';
|
||||
const days = Math.floor(diffMs / 864e5);
|
||||
if (days < 1) return 'less than a day';
|
||||
if (days < 31) return `${days} day${days === 1 ? '' : 's'}`;
|
||||
const months = Math.floor(days / 30.44);
|
||||
return `about ${months} month${months === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function formatCountdown(entryDate: Date | null, durationMonths: number): string {
|
||||
if (!entryDate) return '';
|
||||
if (durationMonths === 0) return 'Never expires';
|
||||
@@ -112,7 +148,13 @@
|
||||
</p>
|
||||
<form method="POST" action="?/renameFam" use:enhance>
|
||||
<label class="field-label" for="fam-name">Display name</label>
|
||||
<input id="fam-name" name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
||||
<input
|
||||
id="fam-name"
|
||||
name="name"
|
||||
bind:value={rename}
|
||||
placeholder={fam?.name || 'Family name'}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" size="sm">Rename</Button>
|
||||
</form>
|
||||
{#if fam?.slug}
|
||||
@@ -163,8 +205,8 @@
|
||||
<Button type="submit" size="sm">Set payday</Button>
|
||||
</form>
|
||||
<p class="hint">
|
||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto timezone
|
||||
follows each device.
|
||||
Payday: the week starts on this day and weekly earnings are settled at this time. Auto
|
||||
timezone follows each device.
|
||||
</p>
|
||||
</Card>
|
||||
<Card title="Seasons" cols={1}>
|
||||
@@ -174,7 +216,13 @@
|
||||
<input id="season-name" name="name" placeholder="Season name" required />
|
||||
<div class="color-row">
|
||||
<label for="season-color">Colour</label>
|
||||
<input id="season-color" name="color" type="color" value="#6366f1" class="color-input" />
|
||||
<input
|
||||
id="season-color"
|
||||
name="color"
|
||||
type="color"
|
||||
value="#6366f1"
|
||||
class="color-input"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
</form>
|
||||
@@ -184,7 +232,8 @@
|
||||
<li>
|
||||
<span class="dot" style="background:{s.color}"></span>
|
||||
{s.name}
|
||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button>
|
||||
<Button variant="danger" size="sm" onclick={() => (deletingSeason = s)}>Remove</Button
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -195,10 +244,18 @@
|
||||
<div class="overlay" onclick={() => (deletingSeason = null)} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Delete "{deletingSeason.name}"?</h3>
|
||||
<p class="warning">All chores assigned to this season will also be removed. This cannot be undone.</p>
|
||||
<form method="POST" action="?/deleteSeason" use:enhance={() => {
|
||||
return async ({ result }) => { if (result.type === 'success') deletingSeason = null; };
|
||||
}}>
|
||||
<p class="warning">
|
||||
All chores assigned to this season will also be removed. This cannot be undone.
|
||||
</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteSeason"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success') deletingSeason = null;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={deletingSeason.id} />
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => (deletingSeason = null)}>Cancel</button>
|
||||
@@ -219,7 +276,13 @@
|
||||
<p class="hint">Add a child. They'll pick their own colour after joining.</p>
|
||||
<form method="POST" action="?/addMember" use:enhance>
|
||||
<label class="field-label" for="new-child">New child</label>
|
||||
<input id="new-child" name="name" bind:value={addName} placeholder="Child name" required />
|
||||
<input
|
||||
id="new-child"
|
||||
name="name"
|
||||
bind:value={addName}
|
||||
placeholder="Child name"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" size="sm">Add child</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -235,10 +298,17 @@
|
||||
</span>
|
||||
</span>
|
||||
<span class="member-actions">
|
||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm">Preview</Button>
|
||||
<Button href="/{famSlug}/{handleOf(m.username)}" variant="secondary" size="sm"
|
||||
>Preview</Button
|
||||
>
|
||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={() => confirm('Remove {m.name}?')}>Remove</Button
|
||||
>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
@@ -284,11 +354,17 @@
|
||||
{#if issued?.otp}
|
||||
<div class="mt-3 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||
<p class="text-xs text-slate-500">Code for {issued.name} (valid 20 min):</p>
|
||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">{issued.otp}</p>
|
||||
<p class="my-2 text-center text-4xl font-bold tracking-[0.3em] text-indigo-700">
|
||||
{issued.otp}
|
||||
</p>
|
||||
<p class="invite-url">{invitePath}</p>
|
||||
<div class="actions justify-center">
|
||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}>{copied ? 'Copied!' : 'Copy URL'}</Button>
|
||||
<Button variant="secondary" size="sm" onclick={toggleQR}>{showQR ? 'Hide QR' : 'Show QR'}</Button>
|
||||
<Button variant="secondary" size="sm" onclick={() => copy(inviteUrl)}
|
||||
>{copied ? 'Copied!' : 'Copy URL'}</Button
|
||||
>
|
||||
<Button variant="secondary" size="sm" onclick={toggleQR}
|
||||
>{showQR ? 'Hide QR' : 'Show QR'}</Button
|
||||
>
|
||||
</div>
|
||||
{#if showQR && qrDataUrl}
|
||||
<div class="qr-wrap"><img src={qrDataUrl} alt="QR Code" class="qr" /></div>
|
||||
@@ -301,7 +377,12 @@
|
||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||
<div class="invite-form">
|
||||
<label class="field-label" for="parent-email">Parent email</label>
|
||||
<input id="parent-email" type="email" bind:value={parentInviteEmail} placeholder="parent@example.com" />
|
||||
<input
|
||||
id="parent-email"
|
||||
type="email"
|
||||
bind:value={parentInviteEmail}
|
||||
placeholder="parent@example.com"
|
||||
/>
|
||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||
</div>
|
||||
<p class="hint">They will set up their own password on first login.</p>
|
||||
@@ -311,79 +392,116 @@
|
||||
|
||||
<AccordionItem title="Account">
|
||||
<CardGrid>
|
||||
<!-- Access -->
|
||||
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
||||
{#if hasCode}
|
||||
<p class="hint">
|
||||
Access active via access code
|
||||
{#if data.accessCode?.duration}
|
||||
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
||||
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
||||
{:else}
|
||||
— <strong>never expires</strong>
|
||||
{/if}.
|
||||
</p>
|
||||
<form
|
||||
class="revoke-form"
|
||||
method="POST"
|
||||
action="?/revokeCode"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
||||
else if (result.type === 'failure') accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
||||
<form method="POST" action="?/applyCode" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && result.data) {
|
||||
const d = result.data as { error?: string; ok?: boolean };
|
||||
accessMsg = d.error || 'Code applied — access enabled.';
|
||||
accessCodeInput = '';
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<input name="code" bind:value={accessCodeInput} placeholder="Enter access code" autocomplete="off" />
|
||||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if accessMsg}
|
||||
<p class="access-msg">{accessMsg}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
<!-- Access (codes) only relevant when NOT on a subscription -->
|
||||
{#if fam?.paymentMode !== 'sub'}
|
||||
<!-- Access -->
|
||||
<Card title="Access" cols={1} accent={hasCode ? '#059669' : '#dc2626'}>
|
||||
{#if hasCode}
|
||||
<p class="hint">
|
||||
Access active via access code
|
||||
{#if data.accessCode?.duration}
|
||||
— <strong>{formatCountdown(codeEntryDate, data.accessCode.duration)}</strong>
|
||||
(expires {formatShortDate(addMonthsUTC(codeEntryDate!, data.accessCode.duration))})
|
||||
{:else}
|
||||
— <strong>never expires</strong>
|
||||
{/if}.
|
||||
</p>
|
||||
<form
|
||||
class="revoke-form"
|
||||
method="POST"
|
||||
action="?/revokeCode"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') accessMsg = 'Access code revoked.';
|
||||
else if (result.type === 'failure')
|
||||
accessMsg = (result.data as any)?.error || 'Revoke failed.';
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="danger">Revoke code</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<p class="hint">No access applied. Enter a valid access code to enable your family.</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/applyCode"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && result.data) {
|
||||
const d = result.data as { error?: string; ok?: boolean };
|
||||
accessMsg = d.error || 'Code applied — access enabled.';
|
||||
accessCodeInput = '';
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="code"
|
||||
bind:value={accessCodeInput}
|
||||
placeholder="Enter access code"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="primary">Apply code</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if accessMsg}
|
||||
<p class="access-msg">{accessMsg}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<Card title="Subscription" cols={1} accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}>
|
||||
<Card
|
||||
title="Subscription"
|
||||
cols={1}
|
||||
accent={fam?.paymentMode === 'sub' ? '#059669' : '#f59e0b'}
|
||||
>
|
||||
<p class="hint">
|
||||
{fam?.paymentMode === 'sub' ? 'Current plan: Subscription.' :
|
||||
fam?.paymentMode === 'code' ? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.` :
|
||||
fam?.paymentMode === 'canceled' ? 'Your subscription was canceled.' : 'No active plan.'}
|
||||
{fam?.paymentMode === 'sub'
|
||||
? 'Current plan: Subscription.'
|
||||
: fam?.paymentMode === 'code'
|
||||
? `Current plan: Access code${hasCode ? '' : ' (invalid)'}.`
|
||||
: fam?.paymentMode === 'canceled'
|
||||
? 'Your subscription was canceled.'
|
||||
: 'No active plan.'}
|
||||
</p>
|
||||
|
||||
{#if fam?.paymentMode === 'sub'}
|
||||
{#if fam?.paymentMode === 'sub' && data.subStatus?.cancelAtPeriodEnd}
|
||||
<!-- Cancel requested: no CTA — just the countdown to expiry -->
|
||||
<p class="hint">
|
||||
<strong>No active subscription.</strong> You have
|
||||
<strong>{timeUntil(data.subStatus.endsAt)}</strong> of access left
|
||||
(until {formatShortDate(String(data.subStatus.endsAt))}). No further charges.
|
||||
</p>
|
||||
{:else if fam?.paymentMode === 'sub'}
|
||||
<div class="actions">
|
||||
<Button href="/pricing" variant="primary" size="sm">Change plan</Button>
|
||||
<form method="POST" action="?/billingPortal" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.portalUrl) {
|
||||
window.open((result.data as any).portalUrl, '_blank');
|
||||
} else if (result.type === 'failure') {
|
||||
accessMsg = (result.data as any)?.error || 'Failed to open billing portal';
|
||||
}
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<Button type="submit" variant="secondary" size="sm" disabled={!fam?.stripeCustomerId}>Open billing portal</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/endSubscription"
|
||||
use:enhance={() => {
|
||||
return async ({ result }) => {
|
||||
if (result.type === 'success' && (result.data as any)?.endsAt) {
|
||||
notices.success(
|
||||
'Subscription ending',
|
||||
`Ends ${formatShortDate(String((result.data as any).endsAt))} — access continues until then, no further charges.`
|
||||
);
|
||||
} else if (result.type === 'failure') {
|
||||
notices.error(
|
||||
'Couldn’t end subscription',
|
||||
(result.data as any)?.error || 'Please try again.'
|
||||
);
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" size="sm">End subscription</Button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="hint resume-hint">
|
||||
To pause your plan, cancel in the billing portal — you keep your data and can resubscribe
|
||||
anytime. Cancelling takes effect at period end.
|
||||
Ending stops future charges at the close of the paid period — your data is kept and you
|
||||
can resubscribe anytime. To switch plans, end this subscription first, then pick a new
|
||||
one from Plans.
|
||||
</p>
|
||||
{:else if fam?.paymentMode === 'code'}
|
||||
<div class="actions">
|
||||
@@ -402,24 +520,36 @@
|
||||
<CardGrid>
|
||||
{#if page.data.platformFlags?.debug}
|
||||
<Card title="Debug Tools" cols={1} accent="#f59e0b">
|
||||
<p class="hint">Debug mode is enabled. These tools are for development and testing only.</p>
|
||||
<p class="hint">
|
||||
Debug mode is enabled. These tools are for development and testing only.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<form method="POST" action="?/completeWeek" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Week completed!');
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/completeWeek"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Week completed!');
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="secondary">Complete Week</Button>
|
||||
</form>
|
||||
<form method="POST" action="?/generateData" use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Test data generated!');
|
||||
await update();
|
||||
};
|
||||
}}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/generateData"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') alert('Test data generated!');
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="days" value="7" />
|
||||
<Button type="submit" size="sm" variant="secondary">Generate Test Data (7 days)</Button>
|
||||
<Button type="submit" size="sm" variant="secondary"
|
||||
>Generate Test Data (7 days)</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -437,6 +567,7 @@
|
||||
</Accordion>
|
||||
|
||||
<NoticeDialog />
|
||||
|
||||
<style>
|
||||
.hint {
|
||||
font-size: 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user