fix some issues - including add ledger page and fix reoccuring completions
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { memberApi } from '$lib/client/api';
|
||||
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
||||
import { formatDDMMYY, formatHumanDate } from '$lib/format';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||
import {
|
||||
@@ -15,7 +15,8 @@
|
||||
wallClockToUtc,
|
||||
resolveTz,
|
||||
periodStart,
|
||||
periodEnd
|
||||
periodEnd,
|
||||
isCompleteForPeriod
|
||||
} from '../../../../../timezone.ts';
|
||||
|
||||
interface Notification {
|
||||
@@ -254,7 +255,8 @@
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() - new Date(todayIso + 'T00:00:00').getTime()) /
|
||||
(new Date(addDays(weekStart, 7) + 'T00:00:00').getTime() -
|
||||
new Date(todayIso + 'T00:00:00').getTime()) /
|
||||
86400000
|
||||
)
|
||||
)
|
||||
@@ -541,14 +543,34 @@
|
||||
}
|
||||
|
||||
function isCompleted(assignedChoreId: string, date: string): boolean {
|
||||
const a = assigned.find((x) => x.id === assignedChoreId);
|
||||
// Weekly chores are "done for the week" — complete if completed any day
|
||||
// in the current week, not just today.
|
||||
if (a?.frequency === 'weekly') {
|
||||
const dates = completions
|
||||
.filter((c) => c.assignedChoreId === assignedChoreId)
|
||||
.map((c) => c.date);
|
||||
return isCompleteForPeriod('weekly', paydayDay, famTz, dates);
|
||||
}
|
||||
return completions.some(
|
||||
(c) => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date
|
||||
);
|
||||
}
|
||||
|
||||
function findCompletion(assignedChoreId: string) {
|
||||
// Todos are one-off: done once the completion row exists, regardless of date.
|
||||
function isTodoDone(todoId: string): boolean {
|
||||
return completions.some((c) => c.assignedChoreId === todoId);
|
||||
}
|
||||
|
||||
function findCompletion(chore: AssignedChore) {
|
||||
const match = (c: Completion) =>
|
||||
c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild;
|
||||
chore.isTodo
|
||||
? c.assignedChoreId === chore.id
|
||||
: chore.frequency === 'weekly'
|
||||
? c.assignedChoreId === chore.id &&
|
||||
(c.date?.slice(0, 10) || c.date) >= weekStart &&
|
||||
(c.date?.slice(0, 10) || c.date) < addDays(weekStart, 7)
|
||||
: c.assignedChoreId === chore.id && (c.date?.slice(0, 10) || c.date) === todayChild;
|
||||
const optimistic = completions.find((c) => match(c) && c.id.startsWith('optimistic-'));
|
||||
return optimistic || completions.find(match);
|
||||
}
|
||||
@@ -557,9 +579,9 @@
|
||||
if (togglingIds) return;
|
||||
togglingIds = chore.id;
|
||||
|
||||
const wasCompleted = isCompleted(chore.id, todayChild);
|
||||
const wasCompleted = chore.isTodo ? isTodoDone(chore.id) : isCompleted(chore.id, todayChild);
|
||||
if (wasCompleted) {
|
||||
const existing = findCompletion(chore.id);
|
||||
const existing = findCompletion(chore);
|
||||
if (existing) {
|
||||
famStore.applyRecord('completions', existing, 'delete');
|
||||
}
|
||||
@@ -653,225 +675,228 @@
|
||||
|
||||
<div class="kanban-scroll">
|
||||
<div class="kanban-inner">
|
||||
<CardGrid>
|
||||
<Card cols={1} title="Members">
|
||||
{#each parentMembers as m}
|
||||
{@const total = totalChoresFor(m.id)}
|
||||
{@const todays = todayCompletionsFor(m.id)}
|
||||
{@const done = todays.length}
|
||||
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
|
||||
{@const s = memberInSummary(m.id)}
|
||||
<div class="member-card">
|
||||
<div class="card-header">
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
<span class="member-name">{m.name}</span>
|
||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||||
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span class="label">Today:</span>
|
||||
<div class="bar-wrap">
|
||||
<div class="bar-fill" style="width:{pct}%"></div>
|
||||
<CardGrid>
|
||||
<Card cols={1} title="Members">
|
||||
{#each parentMembers as m}
|
||||
{@const total = totalChoresFor(m.id)}
|
||||
{@const todays = todayCompletionsFor(m.id)}
|
||||
{@const done = todays.length}
|
||||
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
|
||||
{@const s = memberInSummary(m.id)}
|
||||
<div class="member-card">
|
||||
<div class="card-header">
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
<span class="member-name">{m.name}</span>
|
||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||||
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span class="label">Today:</span>
|
||||
<div class="bar-wrap">
|
||||
<div class="bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
<span class="count">{done}/{total}</span>
|
||||
</div>
|
||||
{#if done > 0}
|
||||
<ul class="done-list">
|
||||
{#each todays as c}
|
||||
<li>
|
||||
✅ {choreNameFor(c.assignedChoreId)}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
use:enhance={() => {
|
||||
return async (args) => handleResult(args);
|
||||
}}
|
||||
class="revoke-form"
|
||||
>
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="count">{done}/{total}</span>
|
||||
</div>
|
||||
{#if done > 0}
|
||||
<ul class="done-list">
|
||||
{#each todays as c}
|
||||
<li>
|
||||
✅ {choreNameFor(c.assignedChoreId)}
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Triggers">
|
||||
{#if manualConfigs().length === 0}
|
||||
<p class="empty">No manual bonus configs.</p>
|
||||
{:else}
|
||||
{#each manualConfigs() as bc}
|
||||
{@const val =
|
||||
bc.rewardType === 'cash'
|
||||
? `£${Number(bc.rewardValue).toFixed(2)}`
|
||||
: bc.rewardType === 'points'
|
||||
? `${bc.rewardValue} pts`
|
||||
: bc.rewardValue}
|
||||
{@const targeted = bc.memberId
|
||||
? parentMembers.find((m: any) => m.id === bc.memberId)
|
||||
: null}
|
||||
<div class="trigger-card">
|
||||
<div class="trigger-head">
|
||||
<span class="trigger-name">{bc.name}</span>
|
||||
<span class="trigger-value">{val}</span>
|
||||
</div>
|
||||
{#if targeted}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/revoke"
|
||||
use:enhance={() => {
|
||||
return async (args) => handleResult(args);
|
||||
}}
|
||||
class="revoke-form"
|
||||
>
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Triggers">
|
||||
{#if manualConfigs().length === 0}
|
||||
<p class="empty">No manual bonus configs.</p>
|
||||
{:else}
|
||||
{#each manualConfigs() as bc}
|
||||
{@const val =
|
||||
bc.rewardType === 'cash'
|
||||
? `£${Number(bc.rewardValue).toFixed(2)}`
|
||||
: bc.rewardType === 'points'
|
||||
? `${bc.rewardValue} pts`
|
||||
: bc.rewardValue}
|
||||
{@const targeted = bc.memberId
|
||||
? parentMembers.find((m: any) => m.id === bc.memberId)
|
||||
: null}
|
||||
<div class="trigger-card">
|
||||
<div class="trigger-head">
|
||||
<span class="trigger-name">{bc.name}</span>
|
||||
<span class="trigger-value">{val}</span>
|
||||
</div>
|
||||
{#if targeted}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/trigger"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<input type="hidden" name="memberId" value={targeted.id} />
|
||||
<div class="trigger-row">
|
||||
<span class="trigger-target" style="color:{targeted.color}"
|
||||
>■ {targeted.name}</span
|
||||
>
|
||||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/trigger"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<div class="trigger-row">
|
||||
<select name="memberId" class="trigger-select">
|
||||
<option value="">Select member</option>
|
||||
{#each parentMembers as m}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Claims" accent="#f59e0b">
|
||||
{#if claimableRewards().length === 0}
|
||||
<p class="empty">No outstanding claims</p>
|
||||
{:else}
|
||||
{#each parentMembers as m}
|
||||
{@const requested = memberRequested(m.id)}
|
||||
{@const outstanding = memberOutstanding(m.id)}
|
||||
{@const claimable = memberClaimable(m.id)}
|
||||
{@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')}
|
||||
{@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')}
|
||||
{@const requestedTotal = cashRequested.reduce(
|
||||
(sum: number, r: any) => sum + Number(r.value),
|
||||
0
|
||||
)}
|
||||
{@const total = cashClaimable.reduce((sum: number, r: any) => sum + Number(r.value), 0)}
|
||||
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
||||
{#if hasClaims}
|
||||
<div class="member-claims">
|
||||
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
||||
{#if total > 0}
|
||||
<p class="member-total">£{total.toFixed(2)} owed</p>
|
||||
{/if}
|
||||
|
||||
<!-- Issue All: only when payday has run (rewards are requested) -->
|
||||
{#if cashRequested.length > 0}
|
||||
<form
|
||||
class="issue-all-form"
|
||||
method="POST"
|
||||
action="?/issueAll"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="memberId" value={m.id} />
|
||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||
<Button type="submit" size="sm" variant="secondary"
|
||||
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if requested.length > 0}
|
||||
<p class="section-label">Requested</p>
|
||||
{#each requested as r}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/claim"
|
||||
action="?/trigger"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||
<div class="payment-row">
|
||||
<span>{r.label}</span>
|
||||
<div class="payment-right">
|
||||
<span class="value">{rewardLabel(r)}</span>
|
||||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||||
</div>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<input type="hidden" name="memberId" value={targeted.id} />
|
||||
<div class="trigger-row">
|
||||
<span class="trigger-target" style="color:{targeted.color}"
|
||||
>■ {targeted.name}</span
|
||||
>
|
||||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if outstanding.length > 0}
|
||||
<p class="section-label">Outstanding</p>
|
||||
{#each outstanding as r}
|
||||
<div class="payment-row outstanding">
|
||||
<span>{r.label}</span>
|
||||
<span class="value">
|
||||
{rewardLabel(r)}
|
||||
{#if paydayLocked(r)}
|
||||
<span class="owe-locked">🔒 {formatShortDate(r.settleDate)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="response-area">
|
||||
<p class="respond-label">Response:</p>
|
||||
<div class="tags">
|
||||
{#each responseTags as tag}
|
||||
<button
|
||||
type="button"
|
||||
class="tag"
|
||||
class:selected={selectedMessage[m.id] === tag}
|
||||
onclick={() =>
|
||||
(selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag)}
|
||||
>{tag}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="custom-msg"
|
||||
placeholder="Or type your own..."
|
||||
bind:value={customMessage[m.id]}
|
||||
oninput={() => {
|
||||
if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id];
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/trigger"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<div class="trigger-row">
|
||||
<select name="memberId" class="trigger-select">
|
||||
<option value="">Select member</option>
|
||||
{#each parentMembers as m}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Claims" accent="#f59e0b">
|
||||
{#if claimableRewards().length === 0}
|
||||
<p class="empty">No outstanding claims</p>
|
||||
{:else}
|
||||
{#each parentMembers as m}
|
||||
{@const requested = memberRequested(m.id)}
|
||||
{@const outstanding = memberOutstanding(m.id)}
|
||||
{@const claimable = memberClaimable(m.id)}
|
||||
{@const cashClaimable = claimable.filter((r: any) => r.rewardType === 'cash')}
|
||||
{@const cashRequested = requested.filter((r: any) => r.rewardType === 'cash')}
|
||||
{@const requestedTotal = cashRequested.reduce(
|
||||
(sum: number, r: any) => sum + Number(r.value),
|
||||
0
|
||||
)}
|
||||
{@const total = cashClaimable.reduce(
|
||||
(sum: number, r: any) => sum + Number(r.value),
|
||||
0
|
||||
)}
|
||||
{@const hasClaims = requested.length > 0 || outstanding.length > 0}
|
||||
{#if hasClaims}
|
||||
<div class="member-claims">
|
||||
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
||||
{#if total > 0}
|
||||
<p class="member-total">£{total.toFixed(2)} owed</p>
|
||||
{/if}
|
||||
|
||||
<!-- Issue All: only when payday has run (rewards are requested) -->
|
||||
{#if cashRequested.length > 0}
|
||||
<form
|
||||
class="issue-all-form"
|
||||
method="POST"
|
||||
action="?/issueAll"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="memberId" value={m.id} />
|
||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||
<Button type="submit" size="sm" variant="secondary"
|
||||
>Issue All ({cashRequested.length}) · £{requestedTotal.toFixed(2)}</Button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if requested.length > 0}
|
||||
<p class="section-label">Requested</p>
|
||||
{#each requested as r}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/claim"
|
||||
use:enhance={() => {
|
||||
return async (args: any) => handleResult(args);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||
<div class="payment-row">
|
||||
<span>{r.label}</span>
|
||||
<div class="payment-right">
|
||||
<span class="value">{rewardLabel(r)}</span>
|
||||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if outstanding.length > 0}
|
||||
<p class="section-label">Outstanding</p>
|
||||
{#each outstanding as r}
|
||||
<div class="payment-row outstanding">
|
||||
<span>{r.label}</span>
|
||||
<span class="value">
|
||||
{rewardLabel(r)}
|
||||
{#if paydayLocked(r)}
|
||||
<span class="owe-locked">🔒 {formatHumanDate(r.settleDate)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="response-area">
|
||||
<p class="respond-label">Response:</p>
|
||||
<div class="tags">
|
||||
{#each responseTags as tag}
|
||||
<button
|
||||
type="button"
|
||||
class="tag"
|
||||
class:selected={selectedMessage[m.id] === tag}
|
||||
onclick={() =>
|
||||
(selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag)}
|
||||
>{tag}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="custom-msg"
|
||||
placeholder="Or type your own..."
|
||||
bind:value={customMessage[m.id]}
|
||||
oninput={() => {
|
||||
if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id];
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
</CardGrid>
|
||||
</div>
|
||||
</div>
|
||||
<CardGrid>
|
||||
@@ -966,8 +991,7 @@
|
||||
{:else}
|
||||
{#if simulateEow}
|
||||
<div class="preview-notice">
|
||||
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid
|
||||
out yet.
|
||||
👀 <b>Payday preview</b> — your parent is checking this week's payday. Nothing is paid out yet.
|
||||
</div>
|
||||
{/if}
|
||||
{#if owedCash > 0}
|
||||
@@ -1205,10 +1229,9 @@
|
||||
</div>
|
||||
<div class="column col-weekly">
|
||||
<h2>
|
||||
📅 Weekly ({weeklyPending.length +
|
||||
memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length})
|
||||
📅 Weekly ({weeklyPending.length + memberTodos.filter((t) => !isTodoDone(t.id)).length})
|
||||
</h2>
|
||||
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isCompleted(t.id, todayChild)).length === 0}
|
||||
{#if weeklyPending.length === 0 && memberTodos.filter((t) => !isTodoDone(t.id)).length === 0}
|
||||
<p class="empty">All done!</p>
|
||||
{:else}
|
||||
{#each weeklyPending as chore}
|
||||
@@ -1218,7 +1241,7 @@
|
||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each memberTodos.filter((t) => !isCompleted(t.id, todayChild)) as todo}
|
||||
{#each memberTodos.filter((t) => !isTodoDone(t.id)) as todo}
|
||||
{@const urgency = todoUrgency(todo)}
|
||||
{#if todo.type === 'emoji'}
|
||||
<div
|
||||
@@ -1307,8 +1330,7 @@
|
||||
{:else if isReq}
|
||||
<span class="wr-pending">⏳ waiting</span>
|
||||
{:else if paydayLocked(r)}
|
||||
<span class="wr-pending wr-locked"
|
||||
>🔒 pays out {formatShortDate(r.settleDate)}</span
|
||||
<span class="wr-pending wr-locked">🔒 pays out {formatHumanDate(r.settleDate)}</span
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
@@ -1369,7 +1391,10 @@
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: white;
|
||||
margin-bottom: 0.6rem;
|
||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.member-card:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -1478,7 +1503,9 @@
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #f0fdf4;
|
||||
margin-bottom: 0.6rem;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.trigger-card:hover {
|
||||
transform: translateY(-1px);
|
||||
|
||||
Reference in New Issue
Block a user