mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-12 18:43:50 +00:00
Merge remote-tracking branch 'origin/master' into 35-replace-navbar-with-a-better-one
This commit is contained in:
+11
-1
@@ -3,4 +3,14 @@ PUBLIC_SUPABASE_ANON_KEY=""
|
|||||||
SUPABASE_SERVICE_ROLE_KEY=""
|
SUPABASE_SERVICE_ROLE_KEY=""
|
||||||
STRIPE_SECRET_KEY=""
|
STRIPE_SECRET_KEY=""
|
||||||
STRIPE_ENDPOINT_SECRET=""
|
STRIPE_ENDPOINT_SECRET=""
|
||||||
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
|
VITE_PRODUCT_ID_EXAMPLEPRODUCT=""
|
||||||
|
VITE_AXIOM_DATASET=""
|
||||||
|
VITE_AXIOM_TOKEN=""
|
||||||
|
# Brevo API configuration for transactional emails (account closure notifications)
|
||||||
|
VITE_BREVO_API_KEY=""
|
||||||
|
# Default sender email for system notifications
|
||||||
|
VITE_BREVO_SENDER_EMAIL="noreply@example.com"
|
||||||
|
# Display name for the sender email
|
||||||
|
VITE_BREVO_SENDER_NAME="No Reply"
|
||||||
|
# Contact email for the contact page form to send to
|
||||||
|
VITE_CONTACT_EMAIL=""
|
||||||
@@ -79,7 +79,15 @@ There are two options to create a migration file:
|
|||||||
|
|
||||||
## Applying migrations
|
## Applying migrations
|
||||||
|
|
||||||
Reset database in cloud
|
To do so without resetting the database, you can use the following command:
|
||||||
|
|
||||||
|
`npx supabase migration up`
|
||||||
|
|
||||||
|
> Use this command when you want to preserve existing data whilst applying new schema changes. For a fresh start with test data, use `npx supabase db reset` instead.
|
||||||
|
|
||||||
|
And for the database in the cloud:
|
||||||
|
|
||||||
|
`npx supabase db push`
|
||||||
|
|
||||||
> Prerequisite login using `npx supabase login`
|
> Prerequisite login using `npx supabase login`
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -48,10 +48,13 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@axiomhq/winston": "^1.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",
|
||||||
"stripe": "^15.4.0",
|
"stripe": "^15.4.0",
|
||||||
"ts-debounce": "^4.0.0"
|
"ts-debounce": "^4.0.0",
|
||||||
|
"winston": "^3.15.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.13.0"
|
"node": ">=18.13.0"
|
||||||
|
|||||||
Generated
+516
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,9 @@
|
|||||||
>
|
>
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
We use cookies to ensure you get the best experience on our website. By continuing, you agree
|
We use cookies to ensure you get the best experience on our website. By continuing, you agree
|
||||||
to our use of cookies.
|
to our use of cookies. For details, please see our <a href="/privacy" class="underline"
|
||||||
|
>Privacy Policy</a
|
||||||
|
>.
|
||||||
</p>
|
</p>
|
||||||
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={acceptCookies}
|
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={acceptCookies}
|
||||||
>Accept</button
|
>Accept</button
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
import { signOut } from '$lib/utils/supabase/auth';
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
function emitSignedOutEvent() {
|
function emitSignedOutEvent() {
|
||||||
@@ -9,13 +10,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: SupabaseClient | null;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
async function signOutClick() {
|
let localBasket: Basket;
|
||||||
if (!supabase) return;
|
const unsubscribe = basket.subscribe((value) => {
|
||||||
await signOut(supabase, emitSignedOutEvent);
|
localBasket = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error signing out:', error.message);
|
||||||
|
alert('Error signing out');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitSignedOutEvent();
|
||||||
|
|
||||||
|
// Clear the basket
|
||||||
|
localBasket = { items: [], subtotal: 0 };
|
||||||
|
basket.set(localBasket);
|
||||||
|
unsubscribe();
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.removeItem('basket');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error clearing basket:', err);
|
||||||
|
alert('Error clearing basket');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click={signOutClick} aria-label="Sign out of your account" type="button">Sign Out</button
|
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
|
||||||
>
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
async function signUpNewUser() {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.auth.signUp({
|
|
||||||
email: email,
|
|
||||||
password: password,
|
|
||||||
options: {
|
|
||||||
// Redirect URL after successful sign-up
|
|
||||||
redirectTo: '/account'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle success (optional)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Sign up error:', error.message);
|
|
||||||
// Handle error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="card-body">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4 opacity-70"
|
|
||||||
><path
|
|
||||||
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
|
|
||||||
/><path
|
|
||||||
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="input input-bordered flex items-center gap-2">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
class="w-4 h-4 opacity-70"
|
|
||||||
><path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mt-6">
|
|
||||||
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -11,10 +11,11 @@
|
|||||||
export let price = 0;
|
export let price = 0;
|
||||||
export let quantity = 0;
|
export let quantity = 0;
|
||||||
|
|
||||||
|
$: dispatch('changeQuantity', { itemId, quantity });
|
||||||
|
|
||||||
function reduceQuantityByOne() {
|
function reduceQuantityByOne() {
|
||||||
if (quantity > 1) {
|
if (quantity > 1) {
|
||||||
quantity--;
|
quantity--;
|
||||||
dispatch('changeQuantity', { itemId, quantity });
|
|
||||||
} else {
|
} else {
|
||||||
dispatch('removeItem', { itemId });
|
dispatch('removeItem', { itemId });
|
||||||
}
|
}
|
||||||
@@ -22,7 +23,6 @@
|
|||||||
|
|
||||||
function increaseQuantityByOne() {
|
function increaseQuantityByOne() {
|
||||||
quantity++;
|
quantity++;
|
||||||
dispatch('changeQuantity', { itemId, quantity });
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Vendored
+26
@@ -34,6 +34,32 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
|
account_deletion_requests: {
|
||||||
|
Row: {
|
||||||
|
requested_at: string
|
||||||
|
token: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
requested_at?: string
|
||||||
|
token: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
requested_at?: string
|
||||||
|
token?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "account_deletion_requests_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "users"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
credit_transactions: {
|
credit_transactions: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import brevo from '@getbrevo/brevo';
|
||||||
|
import {
|
||||||
|
VITE_BREVO_API_KEY,
|
||||||
|
VITE_BREVO_SENDER_EMAIL,
|
||||||
|
VITE_BREVO_SENDER_NAME
|
||||||
|
} from '$env/static/private';
|
||||||
|
|
||||||
|
const apiInstance = new brevo.TransactionalEmailsApi();
|
||||||
|
const apiKey = apiInstance.authentications['apiKey'];
|
||||||
|
if (!VITE_BREVO_API_KEY) {
|
||||||
|
throw new Error('Brevo API key is not configured');
|
||||||
|
}
|
||||||
|
apiKey.apiKey = VITE_BREVO_API_KEY;
|
||||||
|
|
||||||
|
const sendEmail = async (subject: string, htmlContentString: string, toAddress: string) => {
|
||||||
|
const sendSmtpEmail = new brevo.SendSmtpEmail();
|
||||||
|
|
||||||
|
sendSmtpEmail.subject = subject;
|
||||||
|
sendSmtpEmail.htmlContent = htmlContentString;
|
||||||
|
sendSmtpEmail.sender = { name: VITE_BREVO_SENDER_NAME, email: VITE_BREVO_SENDER_EMAIL };
|
||||||
|
sendSmtpEmail.to = [{ email: toAddress, name: toAddress }];
|
||||||
|
sendSmtpEmail.replyTo = { email: VITE_BREVO_SENDER_EMAIL, name: VITE_BREVO_SENDER_NAME };
|
||||||
|
|
||||||
|
apiInstance.sendTransacEmail(sendSmtpEmail).then(
|
||||||
|
function (data) {
|
||||||
|
console.log('API called successfully. Returned data: ' + JSON.stringify(data));
|
||||||
|
},
|
||||||
|
function (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { sendEmail };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createLogger, transports, format } from 'winston';
|
||||||
|
import { WinstonTransport as AxiomTransport } from '@axiomhq/winston';
|
||||||
|
import { VITE_AXIOM_DATASET, VITE_AXIOM_TOKEN } from '$env/static/private';
|
||||||
|
|
||||||
|
const axiomTransport = new AxiomTransport({
|
||||||
|
dataset: VITE_AXIOM_DATASET,
|
||||||
|
token: VITE_AXIOM_TOKEN
|
||||||
|
});
|
||||||
|
|
||||||
|
const logger = createLogger({
|
||||||
|
level: 'info',
|
||||||
|
defaultMeta: { service: 'example-site' }, // Change this so you can identify your website if you have multiple logging into Axiom
|
||||||
|
format: format.combine(
|
||||||
|
format.errors({ stack: true }), // Captures error stack traces
|
||||||
|
format.timestamp(),
|
||||||
|
format.json() // Formats logs as JSON for structured logging
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new transports.Console({
|
||||||
|
format: format.combine(
|
||||||
|
format.colorize(),
|
||||||
|
format.printf(({ timestamp, level, message, stack }) => {
|
||||||
|
// Stack traces will be included in error messages automatically
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${message}${stack ? `\n${stack}` : ''}`;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
axiomTransport
|
||||||
|
],
|
||||||
|
exceptionHandlers: [axiomTransport], // Logs uncaught exceptions
|
||||||
|
rejectionHandlers: [axiomTransport] // Logs unhandled promise rejections
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uncomment this for testing your initial logging setup
|
||||||
|
// logger.log({
|
||||||
|
// level: 'info',
|
||||||
|
// message: 'Logger successfully setup'
|
||||||
|
// });
|
||||||
|
|
||||||
|
export default logger;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
type RateLimitStore = {
|
||||||
|
[key: string]: { count: number; lastRequest: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rateLimitStore: RateLimitStore = {};
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 5000; // 5 minutes
|
||||||
|
const MAX_REQUESTS = 5;
|
||||||
|
|
||||||
|
export function isRateLimited(ip: string): boolean {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const requestInfo = rateLimitStore[ip];
|
||||||
|
|
||||||
|
if (!requestInfo) {
|
||||||
|
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTime - requestInfo.lastRequest > RATE_LIMIT_WINDOW) {
|
||||||
|
rateLimitStore[ip] = { count: 1, lastRequest: currentTime };
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestInfo.count >= MAX_REQUESTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimitStore[ip].count += 1;
|
||||||
|
rateLimitStore[ip].lastRequest = currentTime;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
+193
-36
@@ -4,6 +4,8 @@ import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
|
|||||||
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||||
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
|
import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
|
||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
import { sendEmail } from '../brevo/email';
|
||||||
|
|
||||||
const toDateTime = (secs: number) => {
|
const toDateTime = (secs: number) => {
|
||||||
const t = new Date(+0); // Unix epoch start.
|
const t = new Date(+0); // Unix epoch start.
|
||||||
@@ -39,7 +41,7 @@ const upsertProductRecord = async (product: stripe.Product) => {
|
|||||||
|
|
||||||
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
|
const { error: upsertError } = await supabaseAdmin.from('products').upsert([productData]);
|
||||||
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
|
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
|
||||||
console.log(`Product inserted/updated: ${product.id}`);
|
logger.info(`Product inserted/updated: ${product.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
|
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
|
||||||
@@ -61,7 +63,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
|||||||
|
|
||||||
if (upsertError?.message.includes('foreign key constraint')) {
|
if (upsertError?.message.includes('foreign key constraint')) {
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
console.log(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
logger.info(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
await upsertPriceRecord(price, retryCount + 1, maxRetries);
|
await upsertPriceRecord(price, retryCount + 1, maxRetries);
|
||||||
} else {
|
} else {
|
||||||
@@ -72,7 +74,7 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
|||||||
} else if (upsertError) {
|
} else if (upsertError) {
|
||||||
throw new Error(`Price insert/update failed: ${upsertError.message}`);
|
throw new Error(`Price insert/update failed: ${upsertError.message}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Price inserted/updated: ${price.id}`);
|
logger.info(`Price inserted/updated: ${price.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,13 +84,13 @@ const deleteProductRecord = async (product: stripe.Product) => {
|
|||||||
.delete()
|
.delete()
|
||||||
.eq('id', product.id);
|
.eq('id', product.id);
|
||||||
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
|
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
|
||||||
console.log(`Product deleted: ${product.id}`);
|
logger.info(`Product deleted: ${product.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deletePriceRecord = async (price: stripe.Price) => {
|
const deletePriceRecord = async (price: stripe.Price) => {
|
||||||
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
|
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
|
||||||
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
|
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
|
||||||
console.log(`Price deleted: ${price.id}`);
|
logger.info(`Price deleted: ${price.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
|
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
|
||||||
@@ -254,11 +256,18 @@ const manageSubscriptionStatusChange = async (
|
|||||||
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
|
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
|
||||||
};
|
};
|
||||||
|
|
||||||
const { error: upsertError } = await supabaseAdmin
|
try {
|
||||||
.from('subscriptions')
|
const { error: upsertError } = await supabaseAdmin
|
||||||
.upsert([subscriptionData]);
|
.from('subscriptions')
|
||||||
if (upsertError) throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
|
.upsert([subscriptionData]);
|
||||||
console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
|
if (upsertError) {
|
||||||
|
throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
|
||||||
|
|
||||||
// // For a new subscription copy the billing details to the customer object.
|
// // For a new subscription copy the billing details to the customer object.
|
||||||
// // NOTE: This is a costly operation and should happen at the very end.
|
// // NOTE: This is a costly operation and should happen at the very end.
|
||||||
@@ -271,7 +280,7 @@ const manageSubscriptionStatusChange = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
|
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
|
||||||
console.log(
|
logger.info(
|
||||||
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
|
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -284,40 +293,48 @@ const recordProductPurchase = async (userId: string, productId: string, priceId:
|
|||||||
throw new Error(`Failed to record product purchase: ${error.message}`);
|
throw new Error(`Failed to record product purchase: ${error.message}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Product purchased recorded for user [${userId}] and product [${productId}]`);
|
logger.info(`Product purchased recorded for user [${userId}] and product [${productId}]`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasProductAccess = async (userId: string, productId: string) => {
|
const hasProductAccess = async (userId: string, productId: string) => {
|
||||||
// Check if user has purchased the product
|
// Check if user has purchased the product
|
||||||
const { data: purchases, error: purchaseError } = await supabaseAdmin
|
try {
|
||||||
.from('purchases')
|
const { data: purchases, error: purchaseError } = await supabaseAdmin
|
||||||
.select('*')
|
.from('purchases')
|
||||||
.eq('user_id', userId)
|
.select('*')
|
||||||
.eq('product_id', productId);
|
.eq('user_id', userId)
|
||||||
|
.eq('product_id', productId);
|
||||||
|
|
||||||
if (purchaseError) throw new Error(purchaseError.message);
|
if (purchaseError) throw new Error(purchaseError.message);
|
||||||
|
|
||||||
if (purchases && purchases.length > 0) {
|
if (purchases && purchases.length > 0) {
|
||||||
// User has purchased the product
|
// User has purchased the product
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has an active subscription for the product
|
// Check if user has an active subscription for the product
|
||||||
const { data: subscriptions, error: subError } = await supabaseAdmin
|
try {
|
||||||
.from('subscriptions')
|
const { data: subscriptions, error: subError } = await supabaseAdmin
|
||||||
.select('*')
|
.from('subscriptions')
|
||||||
.eq('user_id', userId)
|
.select('*')
|
||||||
.eq('product_id', productId)
|
.eq('user_id', userId)
|
||||||
.in('status', ['active', 'trialing']);
|
.eq('product_id', productId)
|
||||||
|
.in('status', ['active', 'trialing']);
|
||||||
|
|
||||||
if (subError) throw new Error(subError.message);
|
if (subError) throw new Error(subError.message);
|
||||||
|
|
||||||
if (subscriptions && subscriptions.length > 0) {
|
if (subscriptions && subscriptions.length > 0) {
|
||||||
// User has an active subscription
|
// User has an active subscription
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -381,7 +398,7 @@ const updateUserCredits = async (userId: string, creditsChange: number, descript
|
|||||||
|
|
||||||
await logCreditTransaction(userId, creditsChange, description);
|
await logCreditTransaction(userId, creditsChange, description);
|
||||||
|
|
||||||
console.log(
|
logger.info(
|
||||||
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
|
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -390,7 +407,7 @@ const addCredits = async (userId: string, creditsToAdd: number, description: str
|
|||||||
try {
|
try {
|
||||||
await updateUserCredits(userId, creditsToAdd, description);
|
await updateUserCredits(userId, creditsToAdd, description);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -398,7 +415,7 @@ const deductCredits = async (userId: string, creditsToDeduct: number, descriptio
|
|||||||
try {
|
try {
|
||||||
await updateUserCredits(userId, -creditsToDeduct, description);
|
await updateUserCredits(userId, -creditsToDeduct, description);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
logger.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -480,6 +497,142 @@ const getProductById = async (productId: string) => {
|
|||||||
return product as Product;
|
return product as Product;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestAccountDeletion = async (userId: string, userEmail: string, baseUrl: string) => {
|
||||||
|
const token = crypto.randomUUID();
|
||||||
|
|
||||||
|
const { error: insertError } = await supabaseAdmin.from('account_deletion_requests').upsert({
|
||||||
|
user_id: userId,
|
||||||
|
token,
|
||||||
|
requested_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertError) throw new Error(`Account deletion request failed: ${insertError.message}`);
|
||||||
|
|
||||||
|
const deletionLink = `${baseUrl}/api/delete-account?token=${token}`;
|
||||||
|
|
||||||
|
// Send deletion email using Brevo
|
||||||
|
try {
|
||||||
|
await sendEmail(
|
||||||
|
'Confirm Account Deletion',
|
||||||
|
`<p>Click <a href="${deletionLink}">here</a> to confirm your account deletion.</p>`,
|
||||||
|
userEmail
|
||||||
|
);
|
||||||
|
console.log('Deletion email sent successfully.');
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error('Failed to send deletion email:', emailError);
|
||||||
|
throw new Error('Failed to send confirmation email.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async (token: string) => {
|
||||||
|
// Verify the token
|
||||||
|
const { data: deletionRequest, error } = await supabaseAdmin
|
||||||
|
.from('account_deletion_requests')
|
||||||
|
.select('user_id')
|
||||||
|
.eq('token', token)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !deletionRequest) {
|
||||||
|
return new Response('Invalid or expired token', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// N.B. The SQL here will either cascade delete or nullify the foreign key constraints depending on the table, to enable anonymisation
|
||||||
|
const { error: deletionError } = await supabaseAdmin.auth.admin.deleteUser(
|
||||||
|
deletionRequest.user_id
|
||||||
|
);
|
||||||
|
if (deletionError) {
|
||||||
|
throw new Error(`Failed to delete user: ${deletionError.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserSubscriptions = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
// Step 1: Retrieve subscription data
|
||||||
|
const { data: subscriptions, error } = await supabaseAdmin
|
||||||
|
.from('subscriptions')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('Error retrieving subscriptions:', error.message);
|
||||||
|
throw new Error('Failed to fetch subscriptions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Map through each subscription and fetch product and price details
|
||||||
|
const subscriptionDetails = await Promise.all(
|
||||||
|
subscriptions.map(async (sub) => {
|
||||||
|
// Fetch product and price details from Stripe
|
||||||
|
const product = await stripeClient.products.retrieve(sub.product_id);
|
||||||
|
const price = await stripeClient.prices.retrieve(sub.price_id);
|
||||||
|
|
||||||
|
const expiryDate = new Date(sub.current_period_end);
|
||||||
|
|
||||||
|
// Format output data
|
||||||
|
return {
|
||||||
|
productName: product.name,
|
||||||
|
amount: (price.unit_amount / 100).toFixed(2),
|
||||||
|
currency: price.currency.toUpperCase(),
|
||||||
|
interval: price.recurring?.interval || 'one-time',
|
||||||
|
expiryDate: expiryDate.toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
status: sub.status
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return subscriptionDetails;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('An error occurred while retrieving subscription details:', error);
|
||||||
|
throw new Error('Could not retrieve subscription details');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserTransactions = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
// Step 1: Retrieve the customer ID associated with the user
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from('customers')
|
||||||
|
.select('stripe_customer_id')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error retrieving user data:', error.message);
|
||||||
|
throw new Error('Failed to fetch user data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerId = data?.stripe_customer_id;
|
||||||
|
if (!customerId) {
|
||||||
|
throw new Error('No Stripe customer ID found for this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch charge transactions from Stripe
|
||||||
|
const charges = await stripeClient.charges.list({ customer: customerId });
|
||||||
|
|
||||||
|
// Step 3: Format transaction data
|
||||||
|
const transactions = charges.data.map((charge) => ({
|
||||||
|
amount: (charge.amount / 100).toFixed(2),
|
||||||
|
currency: charge.currency.toUpperCase(),
|
||||||
|
description: charge.description ?? 'No description provided',
|
||||||
|
status: charge.status,
|
||||||
|
created: new Date(charge.created * 1000).toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}),
|
||||||
|
receipt_url: charge.receipt_url
|
||||||
|
}));
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('An error occurred while retrieving transactions:', error);
|
||||||
|
throw new Error('Could not retrieve transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
upsertProductRecord,
|
upsertProductRecord,
|
||||||
upsertPriceRecord,
|
upsertPriceRecord,
|
||||||
@@ -495,5 +648,9 @@ export {
|
|||||||
getActiveProductsWithPrices,
|
getActiveProductsWithPrices,
|
||||||
getProductById,
|
getProductById,
|
||||||
upsertCustomerToSupabase,
|
upsertCustomerToSupabase,
|
||||||
getStripeCustomerId
|
getStripeCustomerId,
|
||||||
|
getUserSubscriptions,
|
||||||
|
getUserTransactions,
|
||||||
|
requestAccountDeletion,
|
||||||
|
deleteAccount
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-4">Cookie Policy</h1>
|
||||||
|
<p class="text-gray-700 mb-6">Effective Date: <span class="font-semibold">[Insert Date]</span></p>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">1. Introduction</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
This Cookie Policy explains how [Your Company/Website Name] ("we", "us", or "our") uses
|
||||||
|
cookies and similar technologies to collect and store information when you visit our website.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">2. What Are Cookies?</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Cookies are small text files stored on your device (computer, tablet, smartphone) by your web
|
||||||
|
browser when you visit a website. They help us remember your preferences and improve your
|
||||||
|
experience on our website.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">3. Types of Cookies We Use</h2>
|
||||||
|
<ul class="list-disc pl-6 text-gray-600">
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Essential Cookies</span>: These cookies are necessary for the
|
||||||
|
website to function properly and cannot be disabled in our systems. They include login
|
||||||
|
authentication and session management cookies.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Performance Cookies</span>: These cookies help us understand how
|
||||||
|
visitors interact with our website by collecting information on usage patterns, such as
|
||||||
|
pages visited and links clicked.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Functional Cookies</span>: These cookies allow us to remember
|
||||||
|
choices you make (such as your language preference) to provide a more personalised
|
||||||
|
experience.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Targeting/Advertising Cookies</span>: These cookies are used to
|
||||||
|
deliver relevant advertisements and track the effectiveness of our marketing campaigns.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">4. How We Use Cookies</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We use cookies to improve the functionality and performance of our website, analyse visitor
|
||||||
|
usage, and provide personalised content and advertisements. This helps us improve our website
|
||||||
|
and better understand our audience.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">5. Managing Your Cookie Preferences</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
You have the right to accept or reject cookies. You can manage your cookie preferences by
|
||||||
|
adjusting your browser settings. However, disabling certain cookies may affect your experience
|
||||||
|
on our website. For specific instructions on managing cookies, please consult your browser's
|
||||||
|
help documentation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">6. Changes to Our Cookie Policy</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We may update this Cookie Policy from time to time. Significant changes will be posted on this
|
||||||
|
page. We encourage you to review this policy periodically to stay informed about our use of
|
||||||
|
cookies.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Contact Us</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
For questions about our Cookie Policy, please contact us at <a
|
||||||
|
href="mailto:your-email@example.com"
|
||||||
|
class="text-primary font-semibold">[Your Contact Email]</a
|
||||||
|
>
|
||||||
|
or visit our <a href="/contact" class="text-primary font-semibold">Contact Page</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-4">Privacy Policy</h1>
|
||||||
|
<p class="text-gray-700 mb-6">Effective Date: <span class="font-semibold">[Insert Date]</span></p>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">1. Introduction</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
This Privacy Policy explains how we at [Your Company/Website Name] collect, use, and protect
|
||||||
|
your personal information when you visit our website. We are committed to safeguarding your
|
||||||
|
data and complying with relevant data protection laws, including the General Data Protection
|
||||||
|
Regulation (GDPR).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">2. Information We Collect</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We collect personal and non-personal information to provide our services and enhance your
|
||||||
|
experience.
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc pl-6 text-gray-600">
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Personal Information</span>: Details like your name, email
|
||||||
|
address, and payment information collected when you make a purchase or create an account.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-semibold">Usage Data</span>: Information such as IP address, browser type,
|
||||||
|
and browsing history for analytics and functionality.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">3. How We Use Your Information</h2>
|
||||||
|
<ul class="list-disc pl-6 text-gray-600">
|
||||||
|
<li>To process orders and manage customer support.</li>
|
||||||
|
<li>To improve website functionality and provide a better user experience.</li>
|
||||||
|
<li>To send marketing emails, if you opt in (you may unsubscribe at any time).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">4. Sharing Information with Third Parties</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We work with trusted third-party providers to offer secure services, such as payment
|
||||||
|
processing and authentication.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-xl font-semibold">Stripe</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
For payment processing, we use <a
|
||||||
|
href="https://stripe.com"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary font-semibold">Stripe</a
|
||||||
|
>. Please refer to their
|
||||||
|
<a href="https://stripe.com/privacy" target="_blank" class="text-primary font-semibold"
|
||||||
|
>Privacy Policy</a
|
||||||
|
> for details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-xl font-semibold">Supabase</h3>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
For database and authentication, we use <a
|
||||||
|
href="https://supabase.com"
|
||||||
|
target="_blank"
|
||||||
|
class="text-primary font-semibold">Supabase</a
|
||||||
|
>. See their
|
||||||
|
<a href="https://supabase.com/privacy" target="_blank" class="text-primary font-semibold"
|
||||||
|
>Privacy Policy</a
|
||||||
|
> for more information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">5. Cookies and Tracking</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We use cookies to provide essential site functionality, analytics, and improved user
|
||||||
|
experience. By continuing to use our site, you consent to our use of cookies. You can manage
|
||||||
|
your cookie preferences in your browser settings. For more details, please refer to our <a
|
||||||
|
href="/cookies"
|
||||||
|
class="text-primary font-semibold">Cookie Policy</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">6. Your Data Rights</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Under GDPR, you have the right to access, correct, delete, and restrict the processing of your
|
||||||
|
personal data. To make a request, please contact us at <a
|
||||||
|
href="mailto:your-email@example.com"
|
||||||
|
class="text-primary font-semibold">[Your Contact Email]</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">7. Data Security</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We take reasonable steps to protect your information through encryption and secure access
|
||||||
|
protocols. However, no online service can be 100% secure.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">8. Policy Updates</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We may update this policy periodically. Significant changes will be posted on this page, so
|
||||||
|
please review it regularly.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold mb-2">Contact Us</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
If you have questions about this Privacy Policy, please contact us at <a
|
||||||
|
href="mailto:your-email@example.com"
|
||||||
|
class="text-primary font-semibold">[Your Contact Email]</a
|
||||||
|
>
|
||||||
|
or visit our <a href="/contact" class="text-primary font-semibold">Contact Page</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -69,8 +69,12 @@
|
|||||||
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"
|
d="M22.672 15.226l-2.432.811.841 2.515c.33 1.019-.209 2.127-1.23 2.456-1.15.325-2.148-.321-2.463-1.226l-.84-2.518-5.013 1.677.84 2.517c.391 1.203-.434 2.542-1.831 2.542-.88 0-1.601-.564-1.86-1.314l-.842-2.516-2.431.809c-1.135.328-2.145-.317-2.463-1.229-.329-1.018.211-2.127 1.231-2.456l2.432-.809-1.621-4.823-2.432.808c-1.355.384-2.558-.59-2.558-1.839 0-.817.509-1.582 1.327-1.846l2.433-.809-.842-2.515c-.33-1.02.211-2.129 1.232-2.458 1.02-.329 2.13.209 2.461 1.229l.842 2.515 5.011-1.677-.839-2.517c-.403-1.238.484-2.553 1.843-2.553.819 0 1.585.509 1.85 1.326l.841 2.517 2.431-.81c1.02-.33 2.131.211 2.461 1.229.332 1.018-.21 2.126-1.23 2.456l-2.433.809 1.622 4.823 2.433-.809c1.242-.401 2.557.484 2.557 1.838 0 .819-.51 1.583-1.328 1.847m-8.992-6.428l-5.01 1.675 1.619 4.828 5.011-1.674-1.62-4.829z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p>Copyright © 2024 - All right reserved</p>
|
<p>Copyright © 2024 - All rights reserved</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div class="grid-flow-col gap-4">
|
||||||
|
<a href="/privacy">Privacy Policy</a>
|
||||||
|
<a href="/cookies">Cookie Policy</a>
|
||||||
|
</div>
|
||||||
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
import { getUserSubscriptions, getUserTransactions } from '$lib/utils/supabase/admin';
|
||||||
|
import { requestAccountDeletion } from '$lib/utils/supabase/admin';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
||||||
const { session } = await safeGetSession();
|
const { session } = await safeGetSession();
|
||||||
@@ -14,10 +17,35 @@ export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession
|
|||||||
.eq('id', session.user.id)
|
.eq('id', session.user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
return { session, user };
|
const subscriptions = await getUserSubscriptions(session.user.id);
|
||||||
|
|
||||||
|
const transactions = await getUserTransactions(session.user.id);
|
||||||
|
|
||||||
|
return { session, user, subscriptions, transactions };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
delete: async ({ url, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
const baseUrl = url.origin;
|
||||||
|
if (session) {
|
||||||
|
if (!session.user.email) {
|
||||||
|
return fail(400, { message: 'Email is required for account deletion' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await requestAccountDeletion(session.user.id, session.user.email, baseUrl);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Account deletion request submitted successfully'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to request account deletion:', error);
|
||||||
|
return fail(500, { message: 'Failed to process deletion request' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
},
|
||||||
update: async ({ request, locals: { supabase, safeGetSession } }) => {
|
update: async ({ request, locals: { supabase, safeGetSession } }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
@@ -27,27 +55,28 @@ export const actions: Actions = {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return fail(401, { name });
|
return fail(401, { name });
|
||||||
}
|
}
|
||||||
const { error } = await supabase.from('users').update({
|
const { error } = await supabase
|
||||||
name: name,
|
.from('users')
|
||||||
}).eq('id', session?.user.id);
|
.update({
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
.eq('id', session?.user.id);
|
||||||
|
|
||||||
console.error(error);
|
if (error) {
|
||||||
|
logger.error('Failed to update user name', {
|
||||||
|
userId: session?.user.id,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return fail(500, {
|
return fail(500, {
|
||||||
name,
|
name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name
|
||||||
};
|
};
|
||||||
},
|
|
||||||
signout: async ({ locals: { supabase, safeGetSession } }) => {
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
if (session) {
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
redirect(303, '/');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+160
-25
@@ -5,7 +5,7 @@
|
|||||||
export let data;
|
export let data;
|
||||||
export let form;
|
export let form;
|
||||||
|
|
||||||
let { session, supabase, user } = data;
|
let { session, user, subscriptions, transactions } = data;
|
||||||
$: ({ session, supabase, user } = data);
|
$: ({ session, supabase, user } = data);
|
||||||
|
|
||||||
let profileForm: HTMLFormElement;
|
let profileForm: HTMLFormElement;
|
||||||
@@ -26,34 +26,169 @@
|
|||||||
update();
|
update();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAccountDeletion: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
if (session.user && session.user.email) {
|
||||||
|
alert('Check your email to confirm account deletion.');
|
||||||
|
} else {
|
||||||
|
alert('Please sign in to delete your account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="form-widget">
|
<div
|
||||||
<form
|
class="account-page max-w-4xl mx-auto p-6 bg-base-100 rounded-lg shadow-md space-y-6 overflow-y-auto"
|
||||||
class="form-widget"
|
>
|
||||||
method="post"
|
<!-- Flex container for two columns -->
|
||||||
action="?/update"
|
<div class="flex flex-col lg:flex-row gap-6">
|
||||||
use:enhance={handleSubmit}
|
<!-- Left Column: Account Overview, Subscriptions, and Billing History -->
|
||||||
bind:this={profileForm}
|
<div class="flex-1 space-y-6">
|
||||||
>
|
<!-- Account Overview -->
|
||||||
<div>
|
<section class="space-y-4">
|
||||||
<label for="name">Name</label>
|
<h2 class="text-xl font-semibold">Account Overview</h2>
|
||||||
<input id="name" name="name" type="text" value={form?.name ?? name} />
|
<div class="form-control">
|
||||||
|
<label for="email" class="label font-medium">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
class="input input-bordered"
|
||||||
|
value={session.user.email}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">Signed in with magic link.</div>
|
||||||
|
<form
|
||||||
|
class="form-widget space-y-4"
|
||||||
|
method="post"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={handleSubmit}
|
||||||
|
bind:this={profileForm}
|
||||||
|
>
|
||||||
|
<div class="form-control">
|
||||||
|
<label for="name" class="label font-medium">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={form?.name ?? name}
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
placeholder="Enter your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
value={loading ? 'Loading...' : 'Update Name'}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Active Subscriptions -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Subscriptions</h2>
|
||||||
|
{#if subscriptions.length === 0}
|
||||||
|
<p class="text-gray-600">You do not have any subscriptions.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#each subscriptions as subscription}
|
||||||
|
<li class="p-4 border border-gray-200 rounded-md">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">{subscription.productName}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Price: {subscription.amount}
|
||||||
|
{subscription.currency} / {subscription.interval}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Expiry Date: {subscription.expiryDate}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Status: {subscription.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- <button class="btn btn-secondary btn-sm">Manage</button> -->
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Billing History -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Billing History</h2>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#if transactions}
|
||||||
|
{#each transactions as transaction}
|
||||||
|
<li class="flex justify-between p-4 border border-gray-200 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Date: {transaction.created}</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Amount: {transaction.amount}
|
||||||
|
{transaction.currency}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if transaction.receipt_url}
|
||||||
|
<a href={transaction.receipt_url} class="btn btn-outline btn-sm" target="_blank">
|
||||||
|
Download Receipt
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-500">Receipt unavailable</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Right Column: Notification Preferences and Feedback -->
|
||||||
<input
|
<div class="flex-none w-full lg:w-64 space-y-6">
|
||||||
type="submit"
|
<!-- Notification Preferences -->
|
||||||
class="button block primary"
|
<section class="space-y-4">
|
||||||
value={loading ? 'Loading...' : 'Update'}
|
<h2 class="text-xl font-semibold">Notification Preferences</h2>
|
||||||
disabled={loading}
|
<form method="post" action="?/update_notifications" class="space-y-2">
|
||||||
/>
|
<label class="flex items-center space-x-3">
|
||||||
</div>
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
</form>
|
<!-- checked={user.notifications.productUpdates} -->
|
||||||
|
<span>Product Updates</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-3">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<!-- checked={user.notifications.billingReminders} -->
|
||||||
|
<span>Billing Reminders</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center space-x-3">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<!-- checked={user.notifications.promotions} -->
|
||||||
|
<span>Promotional Emails</span>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<form method="post" action="?/signout" use:enhance={handleSignOut}>
|
<!-- Feedback Section -->
|
||||||
<div>
|
<section class="space-y-4">
|
||||||
<button class="button block" disabled={loading}>Sign Out</button>
|
<h2 class="text-xl font-semibold">Feedback</h2>
|
||||||
|
<p class="text-gray-600">
|
||||||
|
We'd love to hear your thoughts! Please feel free to provide any feedback.
|
||||||
|
</p>
|
||||||
|
<a href="/contact" class="btn btn-outline w-full">Provide Feedback</a>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
||||||
|
<div>
|
||||||
|
<button class="button block" disabled={loading}>Delete My Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
|
|||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
// Create a checkout session
|
// Create a checkout session
|
||||||
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
||||||
@@ -34,7 +35,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
|
|||||||
try {
|
try {
|
||||||
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Stripe customer ID:', error);
|
logger.error('Error fetching Stripe customer ID:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCheckoutSessionObject = {
|
const stripeCheckoutSessionObject = {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { upsertCustomerToSupabase } from '$lib/utils/supabase/admin';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals: { safeGetSession } }) => {
|
|
||||||
const stripeCustomerId = url.searchParams.get('stripeCustomerId');
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
|
|
||||||
if (!session || !stripeCustomerId) {
|
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertCustomerToSupabase(session?.user.id, stripeCustomerId);
|
|
||||||
|
|
||||||
return json({ message: 'Customer created' });
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { deleteAccount } from '$lib/utils/supabase/admin';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return new Response('Invalid token', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAccount(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response('Failed to delete account', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Account deleted successfully', { status: 200 });
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const GET = async () => {
|
|
||||||
return json(await getSomeData());
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getSomeData() {
|
|
||||||
// Do your async fetching here
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import { type RequestHandler, redirect } from '@sveltejs/kit';
|
|||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
// Create a subscription checkout session
|
// Create a subscription checkout session
|
||||||
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
||||||
@@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, cookies, locals: { safeGet
|
|||||||
try {
|
try {
|
||||||
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Stripe customer ID:', error);
|
logger.error('Error fetching Stripe customer ID:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCheckoutSessionObject = {
|
const stripeCheckoutSessionObject = {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
recordProductPurchase
|
recordProductPurchase
|
||||||
} from '$lib/utils/supabase/admin';
|
} from '$lib/utils/supabase/admin';
|
||||||
import { stripe as stripeClient } from '$lib/utils/stripe';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const body = await request.text();
|
const body = await request.text();
|
||||||
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
try {
|
try {
|
||||||
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
|
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`⚠️ Webhook signature verification failed.`, err.message);
|
logger.error(`⚠️ Webhook signature verification failed.`, err.message);
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {}
|
body: {}
|
||||||
@@ -104,7 +105,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.log(`Unhandled event type ${event.type}`);
|
logger.info(`Unhandled event type ${event.type}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
|
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { getProductById } from '$lib/utils/supabase/admin';
|
import { getProductById, upsertCustomerToSupabase } from '$lib/utils/supabase/admin';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
|
export const load = async ({ fetch, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
export const load = async ({ fetch }) => {
|
|
||||||
const response = await fetch('/api/checkout/status');
|
const response = await fetch('/api/checkout/status');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('Failed to fetch checkout status');
|
logger.error('Failed to fetch checkout status');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,13 +27,11 @@ export const load = async ({ fetch }) => {
|
|||||||
lineItem.productDescription = product.description ?? '';
|
lineItem.productDescription = product.description ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the stripe customer id in the customers table
|
if (session?.user.id) {
|
||||||
const stripeCustomerId = checkoutSession.customer as string;
|
const stripeCustomerId = checkoutSession.customer as string;
|
||||||
const createCustomerResponse = await fetch(
|
await upsertCustomerToSupabase(session.user.id, stripeCustomerId);
|
||||||
'/api/checkout/create-customer?stripeCustomerId=' + stripeCustomerId
|
} else {
|
||||||
);
|
logger.error('User ID is undefined');
|
||||||
if (!createCustomerResponse.ok) {
|
|
||||||
console.error('Failed to create customer');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Actions } from '@sveltejs/kit';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { sendEmail } from '$lib/utils/brevo/email';
|
||||||
|
import { VITE_CONTACT_EMAIL } from '$env/static/private';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
import { isRateLimited } from '$lib/utils/rateLimiter';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const ip =
|
||||||
|
request.headers.get('x-forwarded-for') ?? request.headers.get('remote-addr') ?? 'unknown';
|
||||||
|
|
||||||
|
if (isRateLimited(ip)) {
|
||||||
|
return fail(429, { error: 'Too many requests. Please try again later.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const email = formData.get('email') as string;
|
||||||
|
const message = formData.get('message') as string;
|
||||||
|
|
||||||
|
const emailContent = `
|
||||||
|
<p>You have a new contact form submission:</p>
|
||||||
|
<p><strong>Name:</strong> ${name}</p>
|
||||||
|
<p><strong>Email:</strong> ${email}</p>
|
||||||
|
<p><strong>Message:</strong></p>
|
||||||
|
<p>${message}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail('New Contact Form Submission', emailContent, VITE_CONTACT_EMAIL);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send email:', error);
|
||||||
|
return fail(400, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
let name = '';
|
||||||
|
let email = '';
|
||||||
|
let message = '';
|
||||||
|
let submitted = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
const handleSubmit: SubmitFunction = () => {
|
||||||
|
let loading = false;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
loading = true;
|
||||||
|
if (result.data && result.data.success) {
|
||||||
|
submitted = true;
|
||||||
|
} else {
|
||||||
|
error = result.error?.message || 'Form submission failed';
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if submitted}
|
||||||
|
<div class="alert alert-success shadow-lg">
|
||||||
|
<div>
|
||||||
|
<span>Thank you for your message, {name}!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="?"
|
||||||
|
use:enhance={handleSubmit}
|
||||||
|
class="max-w-lg mx-auto p-4 bg-base-200 rounded-lg shadow-md"
|
||||||
|
aria-labelledby="contact-form-title"
|
||||||
|
>
|
||||||
|
<h2 id="contact-form-title" class="text-xl font-bold mb-4">Contact Us</h2>
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error shadow-lg mb-4">
|
||||||
|
<div>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label for="name" class="label">
|
||||||
|
<span class="label-text">Name *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
bind:value={name}
|
||||||
|
class="input input-bordered"
|
||||||
|
aria-required="true"
|
||||||
|
minlength="2"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label for="email" class="label">
|
||||||
|
<span class="label-text">Email *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
class="input input-bordered"
|
||||||
|
aria-required="true"
|
||||||
|
minlength="5"
|
||||||
|
maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label for="message" class="label">
|
||||||
|
<span class="label-text">Message *</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
bind:value={message}
|
||||||
|
class="textarea textarea-bordered"
|
||||||
|
aria-required="true"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="1000"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
<small class="text-sm mt-1">Maximum 1000 characters</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<button type="submit" class="btn btn-primary">Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
|
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
export const GET = async ({ url }) => {
|
export const GET = async ({ url }) => {
|
||||||
|
const limitParam: string | null = url.searchParams.get('limit');
|
||||||
|
const offsetParam: string | null = url.searchParams.get('offset');
|
||||||
try {
|
try {
|
||||||
const limitParam = url.searchParams.get('limit');
|
const limit = limitParam ? Number.parseInt(limitParam, 10) : 10;
|
||||||
const offsetParam = url.searchParams.get('offset');
|
const offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0;
|
||||||
|
|
||||||
const limit = limitParam ? parseInt(limitParam, 10) : 10;
|
|
||||||
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
||||||
|
|
||||||
const { products, count } = await getActiveProductsWithPrices(limit, offset);
|
const { products, count } = await getActiveProductsWithPrices(limit, offset);
|
||||||
|
|
||||||
return json({ products, count });
|
return json({ products, count });
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(error);
|
if (error instanceof Error) {
|
||||||
return json({ error: error.message }, { status: 500 });
|
logger.error('Failed to fetch products', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
params: {
|
||||||
|
limit: limitParam,
|
||||||
|
offset: offsetParam
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return json({ error: error.message }, { status: 500 });
|
||||||
|
} else {
|
||||||
|
logger.error('Failed to fetch products', {
|
||||||
|
error,
|
||||||
|
params: {
|
||||||
|
limit: limitParam,
|
||||||
|
offset: offsetParam
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return json({ error }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
create table account_deletion_requests (
|
||||||
|
user_id uuid references auth.users not null primary key,
|
||||||
|
token text not null,
|
||||||
|
requested_at timestamp with time zone default now() not null
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table account_deletion_requests enable row level security;
|
||||||
|
create policy "Can view own deletion request data" on account_deletion_requests
|
||||||
|
for select using (auth.uid() = user_id);
|
||||||
|
create policy "Can insert own deletion request data" on account_deletion_requests
|
||||||
|
for insert with check (auth.uid() = user_id);
|
||||||
|
|
||||||
|
ALTER TABLE customers
|
||||||
|
DROP CONSTRAINT IF EXISTS customers_id_fkey;
|
||||||
|
ALTER TABLE customers
|
||||||
|
ADD CONSTRAINT customers_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
DROP CONSTRAINT IF EXISTS subscriptions_user_id_fkey;
|
||||||
|
ALTER TABLE subscriptions
|
||||||
|
ADD CONSTRAINT subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE purchases
|
||||||
|
DROP CONSTRAINT IF EXISTS purchases_user_id_fkey;
|
||||||
|
ALTER TABLE purchases
|
||||||
|
ADD CONSTRAINT purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_credits
|
||||||
|
DROP CONSTRAINT IF EXISTS user_credits_user_id_fkey;
|
||||||
|
ALTER TABLE user_credits
|
||||||
|
ADD CONSTRAINT user_credits_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE credit_transactions
|
||||||
|
DROP CONSTRAINT IF EXISTS credit_transactions_user_id_fkey;
|
||||||
|
ALTER TABLE credit_transactions
|
||||||
|
ADD CONSTRAINT credit_transactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE account_deletion_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS account_deletion_requests_user_id_fkey;
|
||||||
|
ALTER TABLE account_deletion_requests
|
||||||
|
ADD CONSTRAINT account_deletion_requests_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP CONSTRAINT IF EXISTS users_id_fkey;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
Reference in New Issue
Block a user