mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-12 18:43:50 +00:00
Merge pull request #99 from jcreek/54-add-credit-usage-dashboard-for-users
54 add credit usage dashboard for users
This commit is contained in:
@@ -52,6 +52,8 @@
|
|||||||
"@getbrevo/brevo": "^2.2.0",
|
"@getbrevo/brevo": "^2.2.0",
|
||||||
"@supabase/ssr": "^0.4.0",
|
"@supabase/ssr": "^0.4.0",
|
||||||
"@supabase/supabase-js": "^2.44.2",
|
"@supabase/supabase-js": "^2.44.2",
|
||||||
|
"chart.js": "^4.4.6",
|
||||||
|
"simple-datatables": "^9.2.1",
|
||||||
"stripe": "^15.4.0",
|
"stripe": "^15.4.0",
|
||||||
"ts-debounce": "^4.0.0",
|
"ts-debounce": "^4.0.0",
|
||||||
"winston": "^3.15.0"
|
"winston": "^3.15.0"
|
||||||
|
|||||||
Generated
+37
@@ -20,6 +20,12 @@ importers:
|
|||||||
'@supabase/supabase-js':
|
'@supabase/supabase-js':
|
||||||
specifier: ^2.44.2
|
specifier: ^2.44.2
|
||||||
version: 2.45.4
|
version: 2.45.4
|
||||||
|
chart.js:
|
||||||
|
specifier: ^4.4.6
|
||||||
|
version: 4.4.6
|
||||||
|
simple-datatables:
|
||||||
|
specifier: ^9.2.1
|
||||||
|
version: 9.2.1
|
||||||
stripe:
|
stripe:
|
||||||
specifier: ^15.4.0
|
specifier: ^15.4.0
|
||||||
version: 15.12.0
|
version: 15.12.0
|
||||||
@@ -937,6 +943,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.2':
|
||||||
|
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1533,6 +1542,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
|
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==}
|
||||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
|
|
||||||
|
chart.js@4.4.6:
|
||||||
|
resolution: {integrity: sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -1670,6 +1683,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==}
|
resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
dayjs@1.11.13:
|
||||||
|
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||||
|
|
||||||
debug@4.3.7:
|
debug@4.3.7:
|
||||||
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1731,6 +1747,9 @@ packages:
|
|||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
|
diff-dom@5.1.4:
|
||||||
|
resolution: {integrity: sha512-TSEaVdVGictY1KHg7VpVw2nuM02YKC9C8/qBkGiCnkiAybVbu1zQTMj2/dnVLRO7Z62UsqzHGpXweiOj5/jaZg==}
|
||||||
|
|
||||||
dir-glob@3.0.1:
|
dir-glob@3.0.1:
|
||||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2993,6 +3012,9 @@ packages:
|
|||||||
simple-concat@1.0.1:
|
simple-concat@1.0.1:
|
||||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||||
|
|
||||||
|
simple-datatables@9.2.1:
|
||||||
|
resolution: {integrity: sha512-R+KRyZ4GNQdqj5IoW08OYi9FHqCbndbw+MfdOJDnwOGs6me9+BhbNuj2QHZR/GXQ04TLra43Uj2/Lk1KcLiJRw==}
|
||||||
|
|
||||||
simple-get@4.0.1:
|
simple-get@4.0.1:
|
||||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||||
|
|
||||||
@@ -4530,6 +4552,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.2': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -5194,6 +5218,10 @@ snapshots:
|
|||||||
|
|
||||||
chalk@5.3.0: {}
|
chalk@5.3.0: {}
|
||||||
|
|
||||||
|
chart.js@4.4.6:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.2
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -5337,6 +5365,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-data-view: 1.0.1
|
is-data-view: 1.0.1
|
||||||
|
|
||||||
|
dayjs@1.11.13: {}
|
||||||
|
|
||||||
debug@4.3.7:
|
debug@4.3.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -5386,6 +5416,8 @@ snapshots:
|
|||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
|
diff-dom@5.1.4: {}
|
||||||
|
|
||||||
dir-glob@3.0.1:
|
dir-glob@3.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
@@ -6699,6 +6731,11 @@ snapshots:
|
|||||||
|
|
||||||
simple-concat@1.0.1: {}
|
simple-concat@1.0.1: {}
|
||||||
|
|
||||||
|
simple-datatables@9.2.1:
|
||||||
|
dependencies:
|
||||||
|
dayjs: 1.11.13
|
||||||
|
diff-dom: 5.1.4
|
||||||
|
|
||||||
simple-get@4.0.1:
|
simple-get@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
decompress-response: 6.0.0
|
decompress-response: 6.0.0
|
||||||
|
|||||||
@@ -451,6 +451,28 @@ const getUserCredits = async (userId: string) => {
|
|||||||
return creditData.credits_remaining;
|
return creditData.credits_remaining;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the credit transaction history for a specific user.
|
||||||
|
*
|
||||||
|
* @param userId - The ID of the user whose transactions are to be fetched.
|
||||||
|
* @returns Array of transaction records containing credits_change, description, and created_at.
|
||||||
|
*/
|
||||||
|
export const getUserCreditTransactions = async (userId: string) => {
|
||||||
|
if (!userId) throw new Error('User ID is required');
|
||||||
|
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from('credit_transactions')
|
||||||
|
.select('credits_change, description, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false }); // Orders transactions with the latest ones first
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Error fetching transactions: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data || [];
|
||||||
|
};
|
||||||
|
|
||||||
type ProductWithPrices = Product & {
|
type ProductWithPrices = Product & {
|
||||||
prices: Price[];
|
prices: Price[];
|
||||||
actualPrice?: number;
|
actualPrice?: number;
|
||||||
|
|||||||
@@ -139,6 +139,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Credit Usage</h2>
|
||||||
|
<p class="text-gray-600">Monitor your credit balance and view transaction history.</p>
|
||||||
|
<a href="/credits" class="btn btn-secondary w-full">View details</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Billing History -->
|
<!-- Billing History -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-xl font-semibold">Billing History</h2>
|
<h2 class="text-xl font-semibold">Billing History</h2>
|
||||||
@@ -250,12 +256,12 @@
|
|||||||
</p>
|
</p>
|
||||||
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
|
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
||||||
<div>
|
<div>
|
||||||
<button class="button block" disabled={loading}>Delete My Account</button>
|
<button class="btn btn-error w-full" disabled={loading}>Delete My Account</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getUserCredits, getUserCreditTransactions } from '$lib/utils/supabase/admin';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
return { error: 'User not authenticated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const creditsRemaining = await getUserCredits(userId).catch((error) => {
|
||||||
|
logger.error('Failed to fetch user credits:', error);
|
||||||
|
throw new Error('Unable to retrieve credit balance');
|
||||||
|
});
|
||||||
|
const transactions = await getUserCreditTransactions(userId).catch((error) => {
|
||||||
|
logger.error('Failed to fetch credit transactions:', error);
|
||||||
|
throw new Error('Unable to retrieve transaction history');
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthlyAggregates = transactions.reduce((acc, transaction) => {
|
||||||
|
const date = new Date(transaction.created_at);
|
||||||
|
const month = date.toISOString().substring(0, 7); // Returns YYYY-MM format
|
||||||
|
|
||||||
|
if (!acc[month]) acc[month] = { credits_added: 0, credits_deducted: 0 };
|
||||||
|
|
||||||
|
if (transaction.credits_change > 0) {
|
||||||
|
acc[month].credits_added += transaction.credits_change;
|
||||||
|
} else {
|
||||||
|
acc[month].credits_deducted += transaction.credits_change;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
creditsRemaining,
|
||||||
|
transactions,
|
||||||
|
monthlyAggregates
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Chart, { type ChartData } from 'chart.js/auto';
|
||||||
|
import { DataTable } from 'simple-datatables';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
const { creditsRemaining, transactions, monthlyAggregates } = data;
|
||||||
|
|
||||||
|
let chartCanvas: HTMLCanvasElement | null = null;
|
||||||
|
let creditsChart: Chart | null = null;
|
||||||
|
let chartType: 'bar' | 'line' = 'bar';
|
||||||
|
let chartData: ChartData | null = null;
|
||||||
|
|
||||||
|
const monthlyLabels = Object.keys(monthlyAggregates).reverse();
|
||||||
|
const creditsAdded = monthlyLabels.map((month) => monthlyAggregates[month].credits_added);
|
||||||
|
const creditsDeducted = monthlyLabels.map((month) =>
|
||||||
|
Math.abs(monthlyAggregates[month].credits_deducted)
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedTransactions = transactions.reduce((acc, transaction) => {
|
||||||
|
const date = new Date(transaction.created_at);
|
||||||
|
const month = date.toISOString().substring(0, 7); // Returns YYYY-MM format
|
||||||
|
if (!acc[month]) acc[month] = [];
|
||||||
|
acc[month].push(transaction);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
let cumulativeBalance = 0;
|
||||||
|
const cumulativeBalanceData = monthlyLabels.map((month) => {
|
||||||
|
const monthTransactions = groupedTransactions[month] || [];
|
||||||
|
const monthCreditsChange = monthTransactions.reduce((sum, transaction) => {
|
||||||
|
return sum + transaction.credits_change;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
cumulativeBalance += monthCreditsChange;
|
||||||
|
|
||||||
|
return cumulativeBalance;
|
||||||
|
});
|
||||||
|
function initialiseChartData() {
|
||||||
|
if (chartType === 'bar') {
|
||||||
|
chartData = {
|
||||||
|
labels: monthlyLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Credits Added',
|
||||||
|
data: creditsAdded,
|
||||||
|
backgroundColor: 'green'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Credits Deducted',
|
||||||
|
data: creditsDeducted,
|
||||||
|
backgroundColor: 'red'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else if (chartType === 'line') {
|
||||||
|
chartData = {
|
||||||
|
labels: monthlyLabels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Cumulative Balance',
|
||||||
|
data: cumulativeBalanceData,
|
||||||
|
borderColor: 'blue',
|
||||||
|
fill: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(type: 'bar' | 'line') {
|
||||||
|
chartType = type;
|
||||||
|
initialiseChartData();
|
||||||
|
if (creditsChart) {
|
||||||
|
creditsChart.config.type = chartType;
|
||||||
|
creditsChart.data = chartData;
|
||||||
|
creditsChart.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initialiseChartData();
|
||||||
|
if (chartCanvas && chartData) {
|
||||||
|
creditsChart = new Chart(chartCanvas, {
|
||||||
|
type: chartType,
|
||||||
|
data: chartData,
|
||||||
|
options: { responsive: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataTable = new DataTable('#transaction-history');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="flex flex-col items-center p-4">
|
||||||
|
<h1 class="text-3xl font-bold mb-4">Credit Dashboard</h1>
|
||||||
|
<p class="text-lg">Credits Remaining: {creditsRemaining}</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center my-4">
|
||||||
|
<button on:click={() => updateChart('bar')} class:font-bold={chartType === 'bar'}
|
||||||
|
>Monthly Usage</button
|
||||||
|
>
|
||||||
|
<button on:click={() => updateChart('line')} class:font-bold={chartType === 'line'}
|
||||||
|
>Cumulative Balance</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full md:w-3/4 mt-6">
|
||||||
|
<canvas bind:this={chartCanvas} class="my-8"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Transaction History</h2>
|
||||||
|
<div class="overflow-x-auto w-full md:w-3/4">
|
||||||
|
{#if transactions.length === 0}
|
||||||
|
<p class="text-lg">No transactions found.</p>
|
||||||
|
{:else}
|
||||||
|
<table id="transaction-history" class="table-auto w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
<th class="px-4 py-2">Date</th>
|
||||||
|
<th class="px-4 py-2">Description</th>
|
||||||
|
<th class="px-4 py-2">Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each transactions as transaction}
|
||||||
|
<tr class="border-b hover:bg-gray-100">
|
||||||
|
<td class="px-4 py-2">{new Date(transaction.created_at).toLocaleDateString()}</td>
|
||||||
|
<td class="px-4 py-2">{transaction.description}</td>
|
||||||
|
<td
|
||||||
|
class="px-4 py-2"
|
||||||
|
class:text-green-600={transaction.credits_change > 0}
|
||||||
|
class:text-red-600={transaction.credits_change < 0}
|
||||||
|
>
|
||||||
|
{transaction.credits_change > 0 ? '+' : ''}{transaction.credits_change}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin: 0 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.font-bold {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user