mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-15 20:13:50 +00:00
Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2294c6e276 | |||
| d4f177f24b | |||
| 8569048c3f | |||
| acb400231b | |||
| 3f037bf64e | |||
| 5336d14c54 | |||
| cda0a1232a | |||
| 22940c53df | |||
| acfdb75767 | |||
| 287fb78c0d | |||
| 565d709904 | |||
| 10b5747016 | |||
| 15d2f04754 | |||
| cf5771396c | |||
| 13dd60428d | |||
| b333ec2bef | |||
| 319520e1ca | |||
| 6268eacd6c | |||
| 4cbe02b8a0 | |||
| 9c01db944b | |||
| 7f9958cb24 | |||
| 575b154ef4 | |||
| 0189b20e0c | |||
| ffde37ed15 | |||
| 5a2b10b54b | |||
| 1113d509a8 | |||
| 8fcf650069 | |||
| c25233f58e | |||
| 180325449c | |||
| 1fbfb5c3d2 | |||
| eae3ee99e8 | |||
| 87cd2b6c87 | |||
| 0ccf04fcbc | |||
| 2174336512 | |||
| fb05f0b889 | |||
| 150c882827 | |||
| 0d8bc92dee | |||
| 3b77f926e6 | |||
| 42663e7032 | |||
| 05a50a0ce0 | |||
| d62c92e527 | |||
| 93f41de185 | |||
| 77254c85fd | |||
| 8788b90271 | |||
| 61194d7132 | |||
| b3c774b84c | |||
| 5c5fc245b3 | |||
| 981917cd6f | |||
| 5de84d2ec1 | |||
| c77dc6ac47 | |||
| 62f7525238 | |||
| c3125a0db1 | |||
| 3b1ffb21a6 | |||
| 829d8cc031 | |||
| 6cdc0f0bde | |||
| 4137f85190 | |||
| 58842c6f1e | |||
| b2bfd12a35 | |||
| 6a10ab5983 | |||
| 7ec5c5bb38 | |||
| 4f4f122702 | |||
| 19f9b077d6 | |||
| a8c702123e | |||
| af4b7c4e4c | |||
| dc7f6ba18e | |||
| 0be329ff06 | |||
| 39701b2155 | |||
| ed6dc6db74 | |||
| eec6e2041c | |||
| df8af5146f | |||
| 7a91eb5173 | |||
| 57a800ef2c | |||
| 3cd3200d1e | |||
| d329989230 | |||
| 026524a1b3 | |||
| a5a14a93ef | |||
| f032005d8f | |||
| 86ced6e7f9 | |||
| 8a251f775a | |||
| ad30f04078 | |||
| 506ceefe26 | |||
| de565bb965 | |||
| 3bbbd340c5 | |||
| d62b17ad65 | |||
| 259d83a71e | |||
| 26426f9804 | |||
| 1250261afe | |||
| f1f2fe99d7 | |||
| 3189d8932b | |||
| fe1f49090c | |||
| 9253926c7d | |||
| ddac66e3a9 | |||
| 3d89222eec | |||
| 2ef478e4b1 | |||
| 67b748b88e | |||
| c23de036c7 | |||
| 478ff600d3 | |||
| cb1321825d | |||
| 464f365085 | |||
| 7de7ccf9aa | |||
| 8540a2c50f | |||
| d49f4b2c38 | |||
| b83b02fd12 | |||
| 550d9bac18 | |||
| 21fe0cdab8 | |||
| 00fb76ca4f | |||
| c4865337ee | |||
| 33409bc375 | |||
| 90875517da | |||
| 4be732005e | |||
| 7c6afd169c | |||
| e4ef376064 | |||
| 8bc8399b5f | |||
| f28f250fab | |||
| 08a6f6d7cc | |||
| 243dc883ac | |||
| 3a5e961d54 | |||
| 6ed1b25583 | |||
| 867f0f43f6 | |||
| a16959d7ba | |||
| d6baf11a7c | |||
| f20e856d44 | |||
| ec8adbf807 | |||
| 5b7eafc294 | |||
| 6959bcdd04 | |||
| 090224fc70 | |||
| 24a70f123a | |||
| eb979add7d | |||
| 5744ad4db8 | |||
| cd65181e3e | |||
| 532ac0cbe5 | |||
| e47fa54ea8 | |||
| a33779ce53 | |||
| 570b11ef2a | |||
| 7825d29081 | |||
| f3e6c236d5 | |||
| 4c44f772e5 | |||
| 7d994d1c09 | |||
| 03157a6d2f | |||
| 7e458b7297 | |||
| b61983368c | |||
| 43bd4e8026 | |||
| ec684f9074 | |||
| 0b2b0fc7f3 | |||
| 8eb309e393 | |||
| 29f418cc91 | |||
| f34954f45a | |||
| 616009ea05 | |||
| 59258a4750 | |||
| f46b6359ab | |||
| 79e2dcb744 | |||
| 15913ad413 | |||
| 8f7075db28 | |||
| 8221224255 | |||
| 36a8620e70 | |||
| e666aa95d0 | |||
| 95a0c1dee5 | |||
| 73cb21d9de | |||
| e51aeb7c63 | |||
| 2f7ec9860a | |||
| 5d83c79b97 | |||
| db3425ec96 | |||
| fcbd69373c | |||
| c9682be7c8 | |||
| c674eb7c8d | |||
| 108b534a9c | |||
| b2d4d005e1 | |||
| d3a134891c | |||
| 5b137cf715 | |||
| fce4c0e0a5 | |||
| a4764c547e | |||
| 05891ad510 | |||
| d4f7f5041d | |||
| b159c33487 | |||
| 780d810597 | |||
| 47305ba570 | |||
| a3873f93fb | |||
| 6e1d38ef58 | |||
| 0a906fa94d | |||
| 32a3feef64 | |||
| e1df81f104 | |||
| ec456a9b22 | |||
| 02a75e1393 | |||
| 9089aa270b | |||
| 7e05b3f361 | |||
| 49a9b079ea | |||
| 2b2e3e6063 | |||
| 6cf1ffe4f4 | |||
| 7e0c06bab3 | |||
| b365d77c16 | |||
| 723f7eddf7 | |||
| 31a81f6312 | |||
| 65fdc1834a | |||
| cd1d4b7d71 | |||
| 35105d31f1 | |||
| f376a942b3 | |||
| 94de247d56 | |||
| d95c8ec6cc | |||
| 0423bfa701 | |||
| 5c0cce9e47 | |||
| ff71f7a0db | |||
| a29df65cbb | |||
| ceba7622a9 | |||
| 3d296c8f2c | |||
| 0990edab2e | |||
| bd560c41e1 | |||
| c76c6f4484 | |||
| a964f19fc4 |
+16
-1
@@ -1,3 +1,18 @@
|
|||||||
PUBLIC_SUPABASE_URL=""
|
PUBLIC_SUPABASE_URL=""
|
||||||
PUBLIC_SUPABASE_ANON_KEY=""
|
PUBLIC_SUPABASE_ANON_KEY=""
|
||||||
PUBLIC_STRIPE_SECRET_KEY=""
|
SUPABASE_SERVICE_ROLE_KEY=""
|
||||||
|
STRIPE_SECRET_KEY=""
|
||||||
|
STRIPE_ENDPOINT_SECRET=""
|
||||||
|
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=""
|
||||||
|
# Logger service name to identify this website
|
||||||
|
VITE_LOGGER_SERVICE_NAME="example-site"
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ npm run dev -- --open
|
|||||||
|
|
||||||
An example `.env` file is provided in the repository. You will need to copy `.env.example` to `.env` and fill in the values with your own credentials.
|
An example `.env` file is provided in the repository. You will need to copy `.env.example` to `.env` and fill in the values with your own credentials.
|
||||||
|
|
||||||
|
If you want a fully local development environment (other than stripe) then you can follow the instructions in the [database setup](/docs/database-setup.md) document and make use of the local supabase instance and a local email service.
|
||||||
|
|
||||||
|
- [Local DB Url](http://localhost:54323/)
|
||||||
|
- [Local Email Monitoring Url](http://localhost:54324/)
|
||||||
|
|
||||||
|
For stripe, you can forward the events to your local server using `stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify` in a separate terminal window.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
To create a production version:
|
To create a production version:
|
||||||
@@ -48,3 +55,7 @@ Please see the Issues tab for `enhancement` tagged issues.
|
|||||||
## [Database setup](/docs/database-setup.md)
|
## [Database setup](/docs/database-setup.md)
|
||||||
|
|
||||||
## [Stripe webhook event listening](/docs/stripe-setup.md)
|
## [Stripe webhook event listening](/docs/stripe-setup.md)
|
||||||
|
|
||||||
|
## Testing the example credits management system
|
||||||
|
|
||||||
|
There is an example system in this project for being able to manage credits that are sold to users. To test this system you can visit the `http://localhost:5173/test/test-credits` route. This will allow you to add credits to a user and then spend them by clicking the buttons on the page, and will show the user's credit balance updating in real time.
|
||||||
|
|||||||
+48
-166
@@ -4,169 +4,14 @@ Databases are stored in Supabase. The auth table for signing up and logging in u
|
|||||||
|
|
||||||
## Initial Setup
|
## Initial Setup
|
||||||
|
|
||||||
To add the tables for recording their active subscriptions and bought products, as well as products and prices, you can use the following SQL script in the Supabase SQL Editor.
|
To add the tables for recording their active subscriptions and bought products, as well as products and prices, you can use the SQL scripts from the `supabase/migrations` folder in the Supabase SQL Editor.
|
||||||
|
|
||||||
```sql
|
## Common commands for local development
|
||||||
/**
|
|
||||||
* USERS
|
|
||||||
* Note: This table contains user data. Users should only be able to view and update their own data.
|
|
||||||
*/
|
|
||||||
create table users (
|
|
||||||
-- UUID from auth.users
|
|
||||||
id uuid references auth.users not null primary key,
|
|
||||||
full_name text,
|
|
||||||
avatar_url text,
|
|
||||||
-- The customer's billing address, stored in JSON format.
|
|
||||||
billing_address jsonb,
|
|
||||||
-- Stores your customer's payment instruments.
|
|
||||||
payment_method jsonb
|
|
||||||
);
|
|
||||||
alter table users
|
|
||||||
enable row level security;
|
|
||||||
create policy "Can view own user data." on users
|
|
||||||
for select using ((select auth.uid()) = id);
|
|
||||||
create policy "Can update own user data." on users
|
|
||||||
for update using ((select auth.uid()) = id);
|
|
||||||
|
|
||||||
/**
|
- `npx supabase start` - Start the local Supabase instance
|
||||||
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
|
- `npx supabase stop` - Stop the local Supabase instance
|
||||||
*/
|
- `npx supabase db reset` - Reset the local database
|
||||||
create function public.handle_new_user()
|
- `npx supabase gen types typescript --local > src/lib/types/supabase.d.ts` - Generate types for the local database
|
||||||
returns trigger as
|
|
||||||
$$
|
|
||||||
begin
|
|
||||||
insert into public.users (id, full_name, avatar_url)
|
|
||||||
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
|
|
||||||
return new;
|
|
||||||
end;
|
|
||||||
$$
|
|
||||||
language plpgsql security definer;
|
|
||||||
|
|
||||||
create trigger on_auth_user_created
|
|
||||||
after insert on auth.users
|
|
||||||
for each row
|
|
||||||
execute procedure public.handle_new_user();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CUSTOMERS
|
|
||||||
* Note: this is a private table that contains a mapping of user IDs to Stripe customer IDs.
|
|
||||||
*/
|
|
||||||
create table customers (
|
|
||||||
-- UUID from auth.users
|
|
||||||
id uuid references auth.users not null primary key,
|
|
||||||
-- The user's customer ID in Stripe. User must not be able to update this.
|
|
||||||
stripe_customer_id text
|
|
||||||
);
|
|
||||||
alter table customers enable row level security;
|
|
||||||
-- No policies as this is a private table that the user must not have access to.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PRODUCTS
|
|
||||||
* Note: products are created and managed in Stripe and synced to our DB via Stripe webhooks.
|
|
||||||
*/
|
|
||||||
create table products (
|
|
||||||
-- Product ID from Stripe, e.g. prod_1234.
|
|
||||||
id text primary key,
|
|
||||||
-- Whether the product is currently available for purchase.
|
|
||||||
active boolean,
|
|
||||||
-- The product's name, meant to be displayable to the customer. Whenever this product is sold via a subscription, name will show up on associated invoice line item descriptions.
|
|
||||||
name text,
|
|
||||||
-- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.
|
|
||||||
description text,
|
|
||||||
-- A URL of the product image in Stripe, meant to be displayable to the customer.
|
|
||||||
image text,
|
|
||||||
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
|
||||||
metadata jsonb
|
|
||||||
);
|
|
||||||
alter table products
|
|
||||||
enable row level security;
|
|
||||||
create policy "Allow public read-only access." on products
|
|
||||||
for select using (true);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PRICES
|
|
||||||
* Note: prices are created and managed in Stripe and synced to our DB via Stripe webhooks.
|
|
||||||
*/
|
|
||||||
create type pricing_type as enum ('one_time', 'recurring');
|
|
||||||
create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
|
|
||||||
create table prices (
|
|
||||||
-- Price ID from Stripe, e.g. price_1234.
|
|
||||||
id text primary key,
|
|
||||||
-- The ID of the prduct that this price belongs to.
|
|
||||||
product_id text references products,
|
|
||||||
-- Whether the price can be used for new purchases.
|
|
||||||
active boolean,
|
|
||||||
-- A brief description of the price.
|
|
||||||
description text,
|
|
||||||
-- The unit amount as a positive integer in the smallest currency unit (e.g., 100 cents for US$1.00 or 100 for ¥100, a zero-decimal currency).
|
|
||||||
unit_amount bigint,
|
|
||||||
-- Three-letter ISO currency code, in lowercase.
|
|
||||||
currency text check (char_length(currency) = 3),
|
|
||||||
-- One of `one_time` or `recurring` depending on whether the price is for a one-time purchase or a recurring (subscription) purchase.
|
|
||||||
type pricing_type,
|
|
||||||
-- The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.
|
|
||||||
interval pricing_plan_interval,
|
|
||||||
-- The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.
|
|
||||||
interval_count integer,
|
|
||||||
-- Default number of trial days when subscribing a customer to this price using [`trial_from_plan=true`](https://stripe.com/docs/api#create_subscription-trial_from_plan).
|
|
||||||
trial_period_days integer,
|
|
||||||
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
|
||||||
metadata jsonb
|
|
||||||
);
|
|
||||||
alter table prices
|
|
||||||
enable row level security;
|
|
||||||
create policy "Allow public read-only access." on prices
|
|
||||||
for select using (true);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SUBSCRIPTIONS
|
|
||||||
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
|
|
||||||
*/
|
|
||||||
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid');
|
|
||||||
create table subscriptions (
|
|
||||||
-- Subscription ID from Stripe, e.g. sub_1234.
|
|
||||||
id text primary key,
|
|
||||||
user_id uuid references auth.users not null,
|
|
||||||
-- The status of the subscription object, one of subscription_status type above.
|
|
||||||
status subscription_status,
|
|
||||||
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
|
||||||
metadata jsonb,
|
|
||||||
-- ID of the price that created this subscription.
|
|
||||||
price_id text references prices,
|
|
||||||
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
|
|
||||||
quantity integer,
|
|
||||||
-- If true the subscription has been canceled by the user and will be deleted at the end of the billing period.
|
|
||||||
cancel_at_period_end boolean,
|
|
||||||
-- Time at which the subscription was created.
|
|
||||||
created timestamp with time zone default timezone('utc'::text, now()) not null,
|
|
||||||
-- Start of the current period that the subscription has been invoiced for.
|
|
||||||
current_period_start timestamp with time zone default timezone('utc'::text, now()) not null,
|
|
||||||
-- End of the current period that the subscription has been invoiced for. At the end of this period, a new invoice will be created.
|
|
||||||
current_period_end timestamp with time zone default timezone('utc'::text, now()) not null,
|
|
||||||
-- If the subscription has ended, the timestamp of the date the subscription ended.
|
|
||||||
ended_at timestamp with time zone default timezone('utc'::text, now()),
|
|
||||||
-- A date in the future at which the subscription will automatically get canceled.
|
|
||||||
cancel_at timestamp with time zone default timezone('utc'::text, now()),
|
|
||||||
-- If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with `cancel_at_period_end`, `canceled_at` will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.
|
|
||||||
canceled_at timestamp with time zone default timezone('utc'::text, now()),
|
|
||||||
-- If the subscription has a trial, the beginning of that trial.
|
|
||||||
trial_start timestamp with time zone default timezone('utc'::text, now()),
|
|
||||||
-- If the subscription has a trial, the end of that trial.
|
|
||||||
trial_end timestamp with time zone default timezone('utc'::text, now())
|
|
||||||
);
|
|
||||||
alter table subscriptions
|
|
||||||
enable row level security;
|
|
||||||
create policy "Can only view own subs data." on subscriptions
|
|
||||||
for select using ((select auth.uid()) = user_id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* REALTIME SUBSCRIPTIONS
|
|
||||||
* Only allow realtime listening on public tables.
|
|
||||||
*/
|
|
||||||
drop publication if exists supabase_realtime;
|
|
||||||
create publication supabase_realtime
|
|
||||||
for table products, prices;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Generating Types
|
## Generating Types
|
||||||
|
|
||||||
@@ -178,11 +23,11 @@ To get the types for the tables, you can follow these instructions taken from [h
|
|||||||
|
|
||||||
`npx supabase init`
|
`npx supabase init`
|
||||||
|
|
||||||
Replace `$PROJECT_REF` with your project reference:
|
Replace `$PROJECT_REF` with your project reference and replace `types/supabase.d.ts` with whatever file you want your types in:
|
||||||
`npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts`
|
`npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > src/lib/types/supabase.d.ts`
|
||||||
|
|
||||||
Replace `types/supabase.d.ts` with whatever file you want your types in:
|
If you are using a local Supabase instance, you can use the following command to generate the types:
|
||||||
`npx supabase gen types typescript --local > types/supabase.d.ts`
|
`npx supabase gen types typescript --local > src/lib/types/supabase.d.ts`
|
||||||
|
|
||||||
## Local DB Development
|
## Local DB Development
|
||||||
|
|
||||||
@@ -196,6 +41,8 @@ npx supabase start
|
|||||||
npx supabase stop
|
npx supabase stop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This will start the local DB and email monitoring services. You can access the web interfaces for the local DB at [http://localhost:54323/](http://localhost:54323/) and the email monitoring at [http://localhost:54324/](http://localhost:54324/).It will also give you the API URL, anon key and service_role key to put in your `.env` file for local development.
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
There are two options to create a migration file:
|
There are two options to create a migration file:
|
||||||
@@ -232,8 +79,43 @@ 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`
|
||||||
|
|
||||||
|
`npx supabase migration up --linked`
|
||||||
|
|
||||||
|
> 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`
|
||||||
|
|
||||||
`npx supabase db reset --linked`
|
`npx supabase db reset --linked`
|
||||||
|
|
||||||
|
## Enabling sending emails with Brevo
|
||||||
|
|
||||||
|
1. Go to the Project Settings in Supabase, then Authentication, then SMTP Settings. You should end up at `https://supabase.com/dashboard/project/[your-project-here]/settings/auth`
|
||||||
|
2. Enable custom SMTP and fill in the required fields with your Brevo credentials.
|
||||||
|
|
||||||
|
- You need to have an email address that you can send stuff from
|
||||||
|
- You need to have a Brevo account
|
||||||
|
- sender email as noreply@yourdomain.com
|
||||||
|
|
||||||
|
3. `https://app.brevo.com/senders/list` to add senders
|
||||||
|
4. Add a sender for noreply@yourdomain.com
|
||||||
|
5. Verify your email domain via DKIM or DMARC
|
||||||
|
6. In Brevo go to `https://app.brevo.com/settings/keys/smtp` to see your SMTP credentials.
|
||||||
|
7. Fill in the SMTP credentials in Supabase.
|
||||||
|
8. Go to `https://supabase.com/dashboard/project/[your-project-here]/auth/url-configuration`
|
||||||
|
9. Add these redirect URLs:
|
||||||
|
|
||||||
|
- `http://localhost:3000`
|
||||||
|
- `http://localhost:3000/**`
|
||||||
|
- `https://www.your-domain.com`
|
||||||
|
- `https://your-domain.com`
|
||||||
|
- `http://localhost:5173`
|
||||||
|
- `http://localhost:5173/**`
|
||||||
|
- Add your netlify.app domain here with **-- at the start, e.g. `http://**--your-netlify-app.netlify.app`
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ stripe listen
|
|||||||
|
|
||||||
Add the secret key to to your .env(.local):
|
Add the secret key to to your .env(.local):
|
||||||
|
|
||||||
`PUBLIC_STRIPE_ENDPOINT_SECRET`
|
`STRIPE_ENDPOINT_SECRET`
|
||||||
|
|
||||||
### Testing the webhook locally
|
## Testing the webhook locally
|
||||||
|
|
||||||
You can use the [Stripe CLI to forward events to your local server](https://docs.stripe.com/webhooks):
|
You can use the [Stripe CLI to forward events to your local server](https://docs.stripe.com/webhooks):
|
||||||
|
|
||||||
@@ -32,4 +32,8 @@ To disable HTTPS certificate verification, use the --skip-verify optional flag.
|
|||||||
stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify
|
stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify
|
||||||
```
|
```
|
||||||
|
|
||||||
TODO - Add instructions for what else to do to get the webhook working locally.
|
## Purchaseable products
|
||||||
|
|
||||||
|
As well as subscriptions you can also create purchaseable products in Stripe. These can be used for one-off payments or for products that are not subscription based. To add these to the application you can follow the example product's pattern.
|
||||||
|
|
||||||
|
There is a config value `VITE_PRODUCT_ID_EXAMPLEPRODUCT` that is used to identify the product in the Stripe checkout session. This is the product code from Stripe. The actual 'product' that users are getting access to is at the `src/routes/tools/exampleproduct` path.
|
||||||
|
|||||||
Generated
-10310
File diff suppressed because it is too large
Load Diff
+15
-4
@@ -6,10 +6,12 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"stripe": "stripe listen --forward-to localhost:5173/api/webhook/stripe",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"generate": "supabase gen types typescript --local > src/lib/types/supabase.d.ts && prettier --write src/lib/types/supabase.d.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
@@ -40,15 +42,24 @@
|
|||||||
"svelte-check": "^3.6.2",
|
"svelte-check": "^3.6.2",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.4.6",
|
||||||
|
"vite-plugin-tailwind-purgecss": "^0.3.3"
|
||||||
},
|
},
|
||||||
"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"
|
"chart.js": "^4.4.6",
|
||||||
|
"simple-datatables": "^9.2.1",
|
||||||
|
"stripe": "^15.4.0",
|
||||||
|
"ts-debounce": "^4.0.0",
|
||||||
|
"winston": "^3.15.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.13.0"
|
"node": ">=18.13.0"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+7458
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
|
||||||
};
|
|
||||||
Vendored
+5
-1
@@ -2,10 +2,14 @@
|
|||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
// and what to do when importing types
|
// and what to do when importing types
|
||||||
import { SupabaseClient, Session, User } from '@supabase/supabase-js';
|
import { SupabaseClient, Session, User } from '@supabase/supabase-js';
|
||||||
import type { Database } from '$types/supabase';
|
import type { Database } from '$lib/types/supabase';
|
||||||
declare global {
|
declare global {
|
||||||
const __DATE__: string;
|
const __DATE__: string;
|
||||||
const __RELOAD_SW__: boolean;
|
const __RELOAD_SW__: boolean;
|
||||||
|
|
||||||
|
// Enable images with query params
|
||||||
|
declare module '*&img';
|
||||||
|
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
interface Locals {
|
interface Locals {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/* Write your global styles here, in PostCSS syntax */
|
|
||||||
+18
-13
@@ -1,23 +1,28 @@
|
|||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
import { createServerClient } from '@supabase/ssr';
|
import { createServerClient } from '@supabase/ssr';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
import type { Database } from '$lib/types/supabase';
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
event.locals.supabase = createServerClient<Database>(
|
||||||
cookies: {
|
PUBLIC_SUPABASE_URL,
|
||||||
getAll: () => event.cookies.getAll(),
|
PUBLIC_SUPABASE_ANON_KEY,
|
||||||
/**
|
{
|
||||||
* SvelteKit's cookies API requires `path` to be explicitly set in
|
cookies: {
|
||||||
* the cookie options. Setting `path` to `/` replicates previous/
|
getAll: () => event.cookies.getAll(),
|
||||||
* standard behavior.
|
/**
|
||||||
*/
|
* SvelteKit's cookies API requires `path` to be explicitly set in
|
||||||
setAll: (cookiesToSet) => {
|
* the cookie options. Setting `path` to `/` replicates previous/
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
* standard behavior.
|
||||||
event.cookies.set(name, value, { ...options, path: '/' });
|
*/
|
||||||
});
|
setAll: (cookiesToSet) => {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => {
|
||||||
|
event.cookies.set(name, value, { ...options, path: '/' });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unlike `supabase.auth.getSession()`, which returns the session _without_
|
* Unlike `supabase.auth.getSession()`, which returns the session _without_
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isBrowser } from '@supabase/ssr';
|
import { onMount } from 'svelte';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
export let cookiesAccepted;
|
export let cookiesAccepted;
|
||||||
let showBanner = !cookiesAccepted;
|
let showBanner = false;
|
||||||
$: showBanner = !cookiesAccepted;
|
|
||||||
|
|
||||||
const acceptCookies = () => {
|
onMount(() => {
|
||||||
if (isBrowser()) {
|
if (!cookiesAccepted) {
|
||||||
localStorage.setItem('cookiesAccepted', 'true');
|
const storedValue = localStorage.getItem('cookiesAccepted');
|
||||||
showBanner = false;
|
showBanner = storedValue === null;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const handleCookiesSubmit = (accepted: boolean) => {
|
||||||
|
localStorage.setItem('cookiesAccepted', accepted.toString());
|
||||||
|
showBanner = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showBanner}
|
{#if showBanner}
|
||||||
<div class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4">
|
<div
|
||||||
|
class="fixed bottom-0 left-0 w-full bg-gray-800 text-white p-4"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
<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={() => handleCookiesSubmit(true)}
|
||||||
>Accept</button
|
>Accept</button
|
||||||
>
|
>
|
||||||
|
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(false)}
|
||||||
|
>Reject</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
export let indicator: boolean = false;
|
||||||
|
export let posRight: boolean = false;
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
let dropdown: HTMLDivElement;
|
||||||
|
|
||||||
|
const DropdownIcon = {
|
||||||
|
closed: '▼',
|
||||||
|
open: '▲'
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const handleClickAnywhere = ({ target }: MouseEvent) => {
|
||||||
|
if (!isOpen || !target) return;
|
||||||
|
const clickTarget = target as HTMLElement;
|
||||||
|
if (!dropdown.contains(clickTarget) || clickTarget.closest('a')) {
|
||||||
|
isOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickAnywhere);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickAnywhere);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={dropdown} class="relative block">
|
||||||
|
<div class="dropdown-title block w-full">
|
||||||
|
<button class="flex w-full" on:click={() => (isOpen = !isOpen)}>
|
||||||
|
{#if indicator}
|
||||||
|
<span class="mr-1">{@html isOpen ? DropdownIcon.open : DropdownIcon.closed}</span>
|
||||||
|
{/if}
|
||||||
|
<slot name="dropdown"></slot>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if isOpen}
|
||||||
|
<ul
|
||||||
|
class:right-0={posRight}
|
||||||
|
class="absolute rounded-box min-w-52 shadow z-[1] m-0 menu p-2 bg-base-100 text-base-content block"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { scheduleToast } from '$lib/stores/toastStore.js';
|
||||||
|
export let supabase: any;
|
||||||
|
const SUCCESS_MESSAGE = 'Check your email for the magic link.';
|
||||||
|
|
||||||
|
let loading = false,
|
||||||
|
email = '';
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
const { error: err } = await supabase.auth.signInWithOtp({ email });
|
||||||
|
|
||||||
|
if (err) scheduleToast(err.message, 'error', 5000);
|
||||||
|
else scheduleToast(SUCCESS_MESSAGE, 'success', 5000);
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="component">
|
||||||
|
<div class="container">
|
||||||
|
<form on:submit|preventDefault={submit}>
|
||||||
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
|
Email
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="email"
|
||||||
|
class="grow"
|
||||||
|
placeholder="Your email address"
|
||||||
|
bind:value={email}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-active btn-primary w-full" on:click disabled={loading}
|
||||||
|
>Send magic link</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.component {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
export let isMobile: boolean = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<li><a href="/products">Products</a></li>
|
|
||||||
<li class={isMobile ? '' : 'dropdown'}>
|
|
||||||
{#if isMobile}
|
|
||||||
<div tabindex="0" role="button" class="">Parent</div>
|
|
||||||
{:else}
|
|
||||||
<details>
|
|
||||||
<summary>Parent</summary>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
<ul
|
|
||||||
class="{isMobile
|
|
||||||
? ''
|
|
||||||
: 'dropdown-content rounded-box w-52 shadow'} z-[1] menu p-2 bg-base-100 text-base-content"
|
|
||||||
>
|
|
||||||
<li><a href="/">Submenu 1</a></li>
|
|
||||||
<li><a href="/">Submenu 2</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><a href="/">Item 3</a></li>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
function emitSignedInEvent() {
|
|
||||||
dispatch('signedIn', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
async function signInWithEmail() {
|
|
||||||
// TODO use the data and error from the response
|
|
||||||
const { data, error } = await supabase.auth
|
|
||||||
.signInWithPassword({
|
|
||||||
email: email,
|
|
||||||
password: password
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
emitSignedInEvent();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function forgotPassword() {
|
|
||||||
if (email === '') {
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Please enter your email address',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
|
|
||||||
redirectTo: 'https://example.com/auth/update-password'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error sending password reset email:', error.message);
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Error sending password reset email. Please try again.',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('Password reset email sent:', data);
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Password reset email sent. Please check your inbox.',
|
|
||||||
toastType: 'success'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
|
|
||||||
<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">
|
|
||||||
<button id="forgot-password" class="label-text-alt link link-hover" on:click={forgotPassword}>
|
|
||||||
Forgot your password?
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mt-2">
|
|
||||||
<button class="btn btn-primary" on:click={signInWithEmail}>Sign In</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
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() {
|
||||||
@@ -7,14 +10,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
// Access the supabase client from the layout data
|
||||||
export let supabase: any;
|
export let supabase: SupabaseClient;
|
||||||
|
|
||||||
|
let localBasket: Basket;
|
||||||
|
const unsubscribe = basket.subscribe((value) => {
|
||||||
|
localBasket = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
// TODO use the error from the response
|
const { error } = await supabase.auth.signOut();
|
||||||
const { error } = await supabase.auth.signOut().then(() => {
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error signing out:', error.message);
|
||||||
|
alert('Error signing out');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
emitSignedOutEvent();
|
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={signOut}>Sign Out</button>
|
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import stripe from 'stripe';
|
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
|
||||||
|
|
||||||
let email = '';
|
|
||||||
let password = '';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await stripeClient.customers.create({
|
|
||||||
email
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await supabase
|
|
||||||
.from('user')
|
|
||||||
.update({
|
|
||||||
customer_id: customer.id
|
|
||||||
})
|
|
||||||
.match({
|
|
||||||
id: data.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(response);
|
|
||||||
|
|
||||||
// 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>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { formatCurrency } from '$lib/utils/currency';
|
import { formatCurrency } from '$lib/utils/currency';
|
||||||
export let imgAlt = '';
|
export let imgAlt = '';
|
||||||
export let imgSrc = '';
|
export let imgSrc = '';
|
||||||
// export let itemCategoryDescription = '';
|
export let itemCategoryDescription = '';
|
||||||
export let itemName = '';
|
export let itemName = '';
|
||||||
export let price = 0;
|
export let price = 0;
|
||||||
export let quantity = 0;
|
export let quantity = 0;
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
<h5 class="font-semibold text-xl leading-8 text-black max-[550px]:text-center">
|
<h5 class="font-semibold text-xl leading-8 text-black max-[550px]:text-center">
|
||||||
{itemName}
|
{itemName}
|
||||||
</h5>
|
</h5>
|
||||||
<!-- <p
|
<p
|
||||||
class="font-normal text-lg leading-8 text-gray-500 my-2 min-[550px]:my-3 max-[550px]:text-center"
|
class="font-normal text-lg leading-8 text-gray-500 my-2 min-[550px]:my-3 max-[550px]:text-center"
|
||||||
>
|
>
|
||||||
{itemCategoryDescription}
|
{itemCategoryDescription}
|
||||||
</p> -->
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<h6
|
<h6
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Session, SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import MagicLink from '../MagicLink.svelte';
|
||||||
|
import NavLinks from './NavLinks.svelte';
|
||||||
|
import SignOut from '../SignOut.svelte';
|
||||||
|
import Dropdown from '../Dropdown.svelte';
|
||||||
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import type { Database } from '$lib/types/supabase';
|
||||||
|
|
||||||
|
export let session: Session | null;
|
||||||
|
export let supabase: SupabaseClient<Database>;
|
||||||
|
|
||||||
|
let localBasket: Basket;
|
||||||
|
const unsubscribeToBasket = basket.subscribe((value) => {
|
||||||
|
localBasket = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeToBasket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="bg-primary text-primary-content p-4">
|
||||||
|
<!-- Nav Container -->
|
||||||
|
<div class="container mx-auto flex justify-between items-center">
|
||||||
|
<!-- Hamburger Menu Button (Mobile) -->
|
||||||
|
<div class="lg:hidden mr-4">
|
||||||
|
<Dropdown>
|
||||||
|
<div slot="dropdown">
|
||||||
|
<button class="block text-white focus:outline-none" aria-label="Open navigation menu">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h8m-8 6h16"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Dropdown content -->
|
||||||
|
<NavLinks isMobile {session} {supabase} />
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
<!-- Logo -->
|
||||||
|
<a href="/" class="text-white font-bold text-xl">SvelteKit SaaS Boilerplate</a>
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<NavLinks {session} />
|
||||||
|
</div>
|
||||||
|
<!-- Right Side: Basket & User Account -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<!-- Basket/Checkout Icon -->
|
||||||
|
<a href="/checkout" class="btn btn-ghost btn-circle" aria-label="View basket">
|
||||||
|
<div class="indicator">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{#if localBasket.items.length > 0}
|
||||||
|
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<!-- User Account Dropdown -->
|
||||||
|
<Dropdown posRight>
|
||||||
|
<div slot="dropdown">
|
||||||
|
<div
|
||||||
|
class="btn btn-ghost btn-circle avatar placeholder hidden md:flex"
|
||||||
|
aria-label="User account menu"
|
||||||
|
>
|
||||||
|
<div class="w-10 rounded-full">
|
||||||
|
{#if session?.user}
|
||||||
|
<div class="user-circle text-primary-content border-primary-content">
|
||||||
|
<span class="text-xl"
|
||||||
|
>{session?.user.email ? session?.user.email[0].toUpperCase() : '?'}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Dropdown content -->
|
||||||
|
{#if session?.user}
|
||||||
|
<li><a href="/account">Account</a></li>
|
||||||
|
<li><SignOut {supabase} /></li>
|
||||||
|
{:else}
|
||||||
|
<MagicLink {supabase} />
|
||||||
|
{/if}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import SignOut from '../SignOut.svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export let linkItem: NavLinkItem;
|
||||||
|
export let supabase: SupabaseClient | null = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !linkItem.isSignout}
|
||||||
|
<a
|
||||||
|
role="menuitem"
|
||||||
|
href={linkItem.href}
|
||||||
|
aria-current={linkItem.href === $page.url.pathname ? 'page' : undefined}>{linkItem.text}</a
|
||||||
|
>
|
||||||
|
{:else if supabase && linkItem.isSignout}
|
||||||
|
<SignOut {supabase} />
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import Dropdown from '../Dropdown.svelte';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import NavLink from './NavLink.svelte';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export let linkItem: NavLinkItem;
|
||||||
|
export let isMobile: boolean;
|
||||||
|
export let supabase: SupabaseClient | null;
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !isMobile}
|
||||||
|
<Dropdown indicator>
|
||||||
|
<div slot="dropdown">{linkItem.text}</div>
|
||||||
|
{#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
|
||||||
|
<li><NavLink linkItem={child} /></li>
|
||||||
|
{/each}
|
||||||
|
</Dropdown>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class:menu-dropdown-show={isOpen}
|
||||||
|
class="menu-dropdown-toggle"
|
||||||
|
on:click={() => (isOpen = !isOpen)}
|
||||||
|
>
|
||||||
|
{linkItem.text}
|
||||||
|
</button>
|
||||||
|
{#if isOpen}
|
||||||
|
<ul
|
||||||
|
role="menu"
|
||||||
|
class="menu-dropdown menu-dropdown-show"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
|
{#each linkItem.children ?? [] as child (child.text.replace(' ', '-').toLowerCase())}
|
||||||
|
<li><NavLink linkItem={child} {supabase} /></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type NavLinkItem } from '$lib/types/Nav/NavLinkItem';
|
||||||
|
import type { Session } from '@supabase/supabase-js';
|
||||||
|
import NavLinkParent from './NavLinkParent.svelte';
|
||||||
|
import { type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import NavLink from './NavLink.svelte';
|
||||||
|
|
||||||
|
export let session: Session | null;
|
||||||
|
export let supabase: SupabaseClient | null = null;
|
||||||
|
export let isMobile = false;
|
||||||
|
|
||||||
|
const userAccountLinks: NavLinkItem[] = [];
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
if (session) {
|
||||||
|
userAccountLinks.push({
|
||||||
|
text: 'User', // TODO - #62 replace with user's name - will require a database call to get the user's name - suggest setting up a user model to store user data.
|
||||||
|
isParent: true,
|
||||||
|
isUserAccount: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'Account',
|
||||||
|
href: '/account'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Sign Out',
|
||||||
|
isSignout: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
userAccountLinks.push({
|
||||||
|
text: 'Log in',
|
||||||
|
href: '/login'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const links: NavLinkItem[] = [
|
||||||
|
...userAccountLinks,
|
||||||
|
{
|
||||||
|
text: 'Products',
|
||||||
|
href: '/products'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Parent',
|
||||||
|
isParent: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text: 'Submenu 1',
|
||||||
|
href: '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Submenu 2',
|
||||||
|
href: '/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Example Product',
|
||||||
|
href: '/tools/exampleproduct'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul role="menu" class:flex={!isMobile} class:space-x-4={!isMobile}>
|
||||||
|
{#each links as link (link.text.replace(' ', '-').toLowerCase())}
|
||||||
|
<li class:md:hidden={link.isUserAccount}>
|
||||||
|
{#if link.isParent}
|
||||||
|
<NavLinkParent linkItem={link} {isMobile} {supabase} />
|
||||||
|
{:else}
|
||||||
|
<NavLink linkItem={link} />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let product;
|
export let product;
|
||||||
|
|
||||||
|
const defaultPrice = product.prices[0];
|
||||||
|
const isSubscription = defaultPrice.type === 'recurring';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/products/{product.id}" class="max-w-[384px] mx-auto">
|
<a href="/products/{product.id}" class="max-w-[384px] mx-auto">
|
||||||
@@ -13,7 +16,7 @@
|
|||||||
<div class="mt-5 flex items-center justify-between">
|
<div class="mt-5 flex items-center justify-between">
|
||||||
<div class="">
|
<div class="">
|
||||||
<h6 class="font-medium text-xl leading-8 text-black mb-2">{product.name}</h6>
|
<h6 class="font-medium text-xl leading-8 text-black mb-2">{product.name}</h6>
|
||||||
<h6 class="font-semibold text-xl leading-8 text-indigo-600">£{product.actualPrice}</h6>
|
<h6 class="font-semibold text-xl leading-8 text-indigo-600">£{product.actualPrice} {isSubscription ? `/ ${defaultPrice.interval}` : ''}</h6>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="p-2 min-[400px]:p-4 rounded-full bg-white border border-gray-300 flex items-center justify-center group shadow-sm shadow-transparent transition-all duration-500 hover:shadow-gray-200 hover:border-gray-400 hover:bg-gray-50"
|
class="p-2 min-[400px]:p-4 rounded-full bg-white border border-gray-300 flex items-center justify-center group shadow-sm shadow-transparent transition-all duration-500 hover:shadow-gray-200 hover:border-gray-400 hover:bg-gray-50"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import { cubicInOut } from 'svelte/easing';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { toast } from '$lib/stores/toastStore.js';
|
||||||
|
import type { Toast } from '$lib/types/Shared/Toast';
|
||||||
|
|
||||||
|
let localToast: Toast;
|
||||||
|
const unsubscribeToToastStore = toast.subscribe((value: Toast) => {
|
||||||
|
localToast = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeToToastStore();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !localToast.hideToast}
|
||||||
|
<div
|
||||||
|
id={localToast.toastType == 'success' ? 'success-toast' : 'error-toast'}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
class="alert {localToast.toastType == 'success'
|
||||||
|
? 'alert-success'
|
||||||
|
: 'alert-error'} fixed top-20 right-3 max-w-80 z-50 text-white"
|
||||||
|
transition:slide={{ duration: 200, easing: cubicInOut }}
|
||||||
|
>
|
||||||
|
{#if localToast.toastType == 'success'}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="stroke-current shrink-0 h-6 w-6"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
><g
|
||||||
|
><g id="Error_1_"
|
||||||
|
><g id="Error">
|
||||||
|
<circle cx="16" cy="16" id="BG" r="16" style="fill:#E6E6E6;" />
|
||||||
|
<path
|
||||||
|
d="M14.5,25h3v-3h-3V25z M14.5,6v13h3V6H14.5z"
|
||||||
|
id="Exclamatory_x5F_Sign"
|
||||||
|
style="fill:#D72828;"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g></g
|
||||||
|
></svg
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span>{localToast.toastMessage}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const general = writable({ hideToast: true, toastMessage: '', toastType: 'success' });
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Toast } from '$lib/types/Shared/Toast';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
const MIN_DELAY = 3000;
|
||||||
|
|
||||||
|
export const toast = writable<Toast>({ hideToast: true, toastMessage: '', toastType: 'success' });
|
||||||
|
export function scheduleToast(message: string, toastType: 'success' | 'error', delay: number = MIN_DELAY) {
|
||||||
|
|
||||||
|
toast.update((value) => {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
hideToast: false,
|
||||||
|
toastMessage: message,
|
||||||
|
toastType: toastType
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.update((value) => {
|
||||||
|
return { ...value, hideToast: true, toastMessage: '' };
|
||||||
|
});
|
||||||
|
}, Math.max(delay, MIN_DELAY));
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface NavLinkItem {
|
||||||
|
text: string;
|
||||||
|
href?: string;
|
||||||
|
isParent?: boolean;
|
||||||
|
children?: NavLinkItem[];
|
||||||
|
isSignout?: boolean;
|
||||||
|
isUserAccount?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
export type ProductInfo = {
|
||||||
|
product: Stripe.Product;
|
||||||
|
price: Stripe.Price;
|
||||||
|
isSubscription: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type Toast = {
|
||||||
|
hideToast: boolean,
|
||||||
|
toastMessage: string,
|
||||||
|
toastType: 'success' | 'error'
|
||||||
|
}
|
||||||
+162
-330
@@ -34,6 +34,64 @@ 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: {
|
||||||
|
Row: {
|
||||||
|
created_at: string
|
||||||
|
credits_change: number
|
||||||
|
description: string | null
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
created_at?: string
|
||||||
|
credits_change: number
|
||||||
|
description?: string | null
|
||||||
|
id?: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
created_at?: string
|
||||||
|
credits_change?: number
|
||||||
|
description?: string | null
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "credit_transactions_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "users"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
customers: {
|
customers: {
|
||||||
Row: {
|
Row: {
|
||||||
id: string
|
id: string
|
||||||
@@ -66,7 +124,7 @@ export type Database = {
|
|||||||
interval: Database["public"]["Enums"]["pricing_plan_interval"] | null
|
interval: Database["public"]["Enums"]["pricing_plan_interval"] | null
|
||||||
interval_count: number | null
|
interval_count: number | null
|
||||||
metadata: Json | null
|
metadata: Json | null
|
||||||
product_id: string | null
|
product_id: string
|
||||||
trial_period_days: number | null
|
trial_period_days: number | null
|
||||||
type: Database["public"]["Enums"]["pricing_type"] | null
|
type: Database["public"]["Enums"]["pricing_type"] | null
|
||||||
unit_amount: number | null
|
unit_amount: number | null
|
||||||
@@ -79,7 +137,7 @@ export type Database = {
|
|||||||
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
|
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
|
||||||
interval_count?: number | null
|
interval_count?: number | null
|
||||||
metadata?: Json | null
|
metadata?: Json | null
|
||||||
product_id?: string | null
|
product_id: string
|
||||||
trial_period_days?: number | null
|
trial_period_days?: number | null
|
||||||
type?: Database["public"]["Enums"]["pricing_type"] | null
|
type?: Database["public"]["Enums"]["pricing_type"] | null
|
||||||
unit_amount?: number | null
|
unit_amount?: number | null
|
||||||
@@ -92,7 +150,7 @@ export type Database = {
|
|||||||
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
|
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
|
||||||
interval_count?: number | null
|
interval_count?: number | null
|
||||||
metadata?: Json | null
|
metadata?: Json | null
|
||||||
product_id?: string | null
|
product_id?: string
|
||||||
trial_period_days?: number | null
|
trial_period_days?: number | null
|
||||||
type?: Database["public"]["Enums"]["pricing_type"] | null
|
type?: Database["public"]["Enums"]["pricing_type"] | null
|
||||||
unit_amount?: number | null
|
unit_amount?: number | null
|
||||||
@@ -110,30 +168,85 @@ export type Database = {
|
|||||||
products: {
|
products: {
|
||||||
Row: {
|
Row: {
|
||||||
active: boolean | null
|
active: boolean | null
|
||||||
|
created_at: string
|
||||||
description: string | null
|
description: string | null
|
||||||
|
features: string[] | null
|
||||||
id: string
|
id: string
|
||||||
image: string | null
|
images: string[] | null
|
||||||
metadata: Json | null
|
metadata: Json | null
|
||||||
name: string | null
|
name: string | null
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
active?: boolean | null
|
active?: boolean | null
|
||||||
|
created_at?: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
features?: string[] | null
|
||||||
id: string
|
id: string
|
||||||
image?: string | null
|
images?: string[] | null
|
||||||
metadata?: Json | null
|
metadata?: Json | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
|
updated_at?: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
active?: boolean | null
|
active?: boolean | null
|
||||||
|
created_at?: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
features?: string[] | null
|
||||||
id?: string
|
id?: string
|
||||||
image?: string | null
|
images?: string[] | null
|
||||||
metadata?: Json | null
|
metadata?: Json | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
|
updated_at?: string
|
||||||
}
|
}
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
purchases: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
price_id: string
|
||||||
|
product_id: string
|
||||||
|
purchased_at: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
price_id: string
|
||||||
|
product_id: string
|
||||||
|
purchased_at?: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
price_id?: string
|
||||||
|
product_id?: string
|
||||||
|
purchased_at?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "purchases_price_id_fkey"
|
||||||
|
columns: ["price_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "prices"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "purchases_product_id_fkey"
|
||||||
|
columns: ["product_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "products"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "purchases_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "users"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
Row: {
|
Row: {
|
||||||
cancel_at: string | null
|
cancel_at: string | null
|
||||||
@@ -146,6 +259,7 @@ export type Database = {
|
|||||||
id: string
|
id: string
|
||||||
metadata: Json | null
|
metadata: Json | null
|
||||||
price_id: string | null
|
price_id: string | null
|
||||||
|
product_id: string
|
||||||
quantity: number | null
|
quantity: number | null
|
||||||
status: Database["public"]["Enums"]["subscription_status"] | null
|
status: Database["public"]["Enums"]["subscription_status"] | null
|
||||||
trial_end: string | null
|
trial_end: string | null
|
||||||
@@ -163,6 +277,7 @@ export type Database = {
|
|||||||
id: string
|
id: string
|
||||||
metadata?: Json | null
|
metadata?: Json | null
|
||||||
price_id?: string | null
|
price_id?: string | null
|
||||||
|
product_id: string
|
||||||
quantity?: number | null
|
quantity?: number | null
|
||||||
status?: Database["public"]["Enums"]["subscription_status"] | null
|
status?: Database["public"]["Enums"]["subscription_status"] | null
|
||||||
trial_end?: string | null
|
trial_end?: string | null
|
||||||
@@ -180,6 +295,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
metadata?: Json | null
|
metadata?: Json | null
|
||||||
price_id?: string | null
|
price_id?: string | null
|
||||||
|
product_id?: string
|
||||||
quantity?: number | null
|
quantity?: number | null
|
||||||
status?: Database["public"]["Enums"]["subscription_status"] | null
|
status?: Database["public"]["Enums"]["subscription_status"] | null
|
||||||
trial_end?: string | null
|
trial_end?: string | null
|
||||||
@@ -194,6 +310,13 @@ export type Database = {
|
|||||||
referencedRelation: "prices"
|
referencedRelation: "prices"
|
||||||
referencedColumns: ["id"]
|
referencedColumns: ["id"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "subscriptions_product_id_fkey"
|
||||||
|
columns: ["product_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "products"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
foreignKeyName: "subscriptions_user_id_fkey"
|
foreignKeyName: "subscriptions_user_id_fkey"
|
||||||
columns: ["user_id"]
|
columns: ["user_id"]
|
||||||
@@ -203,27 +326,44 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
users: {
|
user_credits: {
|
||||||
Row: {
|
Row: {
|
||||||
avatar_url: string | null
|
credits_remaining: number
|
||||||
billing_address: Json | null
|
last_updated: string
|
||||||
full_name: string | null
|
user_id: string
|
||||||
id: string
|
|
||||||
payment_method: Json | null
|
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
avatar_url?: string | null
|
credits_remaining?: number
|
||||||
billing_address?: Json | null
|
last_updated?: string
|
||||||
full_name?: string | null
|
user_id: string
|
||||||
id: string
|
}
|
||||||
payment_method?: Json | null
|
Update: {
|
||||||
|
credits_remaining?: number
|
||||||
|
last_updated?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "user_credits_user_id_fkey"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: true
|
||||||
|
referencedRelation: "users"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
users: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id: string
|
||||||
|
name?: string | null
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
avatar_url?: string | null
|
|
||||||
billing_address?: Json | null
|
|
||||||
full_name?: string | null
|
|
||||||
id?: string
|
id?: string
|
||||||
payment_method?: Json | null
|
name?: string | null
|
||||||
}
|
}
|
||||||
Relationships: [
|
Relationships: [
|
||||||
{
|
{
|
||||||
@@ -253,315 +393,7 @@ export type Database = {
|
|||||||
| "incomplete_expired"
|
| "incomplete_expired"
|
||||||
| "past_due"
|
| "past_due"
|
||||||
| "unpaid"
|
| "unpaid"
|
||||||
}
|
| "paused"
|
||||||
CompositeTypes: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
storage: {
|
|
||||||
Tables: {
|
|
||||||
buckets: {
|
|
||||||
Row: {
|
|
||||||
allowed_mime_types: string[] | null
|
|
||||||
avif_autodetection: boolean | null
|
|
||||||
created_at: string | null
|
|
||||||
file_size_limit: number | null
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
owner: string | null
|
|
||||||
owner_id: string | null
|
|
||||||
public: boolean | null
|
|
||||||
updated_at: string | null
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
allowed_mime_types?: string[] | null
|
|
||||||
avif_autodetection?: boolean | null
|
|
||||||
created_at?: string | null
|
|
||||||
file_size_limit?: number | null
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
owner?: string | null
|
|
||||||
owner_id?: string | null
|
|
||||||
public?: boolean | null
|
|
||||||
updated_at?: string | null
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
allowed_mime_types?: string[] | null
|
|
||||||
avif_autodetection?: boolean | null
|
|
||||||
created_at?: string | null
|
|
||||||
file_size_limit?: number | null
|
|
||||||
id?: string
|
|
||||||
name?: string
|
|
||||||
owner?: string | null
|
|
||||||
owner_id?: string | null
|
|
||||||
public?: boolean | null
|
|
||||||
updated_at?: string | null
|
|
||||||
}
|
|
||||||
Relationships: []
|
|
||||||
}
|
|
||||||
migrations: {
|
|
||||||
Row: {
|
|
||||||
executed_at: string | null
|
|
||||||
hash: string
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
executed_at?: string | null
|
|
||||||
hash: string
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
executed_at?: string | null
|
|
||||||
hash?: string
|
|
||||||
id?: number
|
|
||||||
name?: string
|
|
||||||
}
|
|
||||||
Relationships: []
|
|
||||||
}
|
|
||||||
objects: {
|
|
||||||
Row: {
|
|
||||||
bucket_id: string | null
|
|
||||||
created_at: string | null
|
|
||||||
id: string
|
|
||||||
last_accessed_at: string | null
|
|
||||||
metadata: Json | null
|
|
||||||
name: string | null
|
|
||||||
owner: string | null
|
|
||||||
owner_id: string | null
|
|
||||||
path_tokens: string[] | null
|
|
||||||
updated_at: string | null
|
|
||||||
version: string | null
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
bucket_id?: string | null
|
|
||||||
created_at?: string | null
|
|
||||||
id?: string
|
|
||||||
last_accessed_at?: string | null
|
|
||||||
metadata?: Json | null
|
|
||||||
name?: string | null
|
|
||||||
owner?: string | null
|
|
||||||
owner_id?: string | null
|
|
||||||
path_tokens?: string[] | null
|
|
||||||
updated_at?: string | null
|
|
||||||
version?: string | null
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
bucket_id?: string | null
|
|
||||||
created_at?: string | null
|
|
||||||
id?: string
|
|
||||||
last_accessed_at?: string | null
|
|
||||||
metadata?: Json | null
|
|
||||||
name?: string | null
|
|
||||||
owner?: string | null
|
|
||||||
owner_id?: string | null
|
|
||||||
path_tokens?: string[] | null
|
|
||||||
updated_at?: string | null
|
|
||||||
version?: string | null
|
|
||||||
}
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "objects_bucketId_fkey"
|
|
||||||
columns: ["bucket_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "buckets"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
s3_multipart_uploads: {
|
|
||||||
Row: {
|
|
||||||
bucket_id: string
|
|
||||||
created_at: string
|
|
||||||
id: string
|
|
||||||
in_progress_size: number
|
|
||||||
key: string
|
|
||||||
owner_id: string | null
|
|
||||||
upload_signature: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
bucket_id: string
|
|
||||||
created_at?: string
|
|
||||||
id: string
|
|
||||||
in_progress_size?: number
|
|
||||||
key: string
|
|
||||||
owner_id?: string | null
|
|
||||||
upload_signature: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
bucket_id?: string
|
|
||||||
created_at?: string
|
|
||||||
id?: string
|
|
||||||
in_progress_size?: number
|
|
||||||
key?: string
|
|
||||||
owner_id?: string | null
|
|
||||||
upload_signature?: string
|
|
||||||
version?: string
|
|
||||||
}
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "s3_multipart_uploads_bucket_id_fkey"
|
|
||||||
columns: ["bucket_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "buckets"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
s3_multipart_uploads_parts: {
|
|
||||||
Row: {
|
|
||||||
bucket_id: string
|
|
||||||
created_at: string
|
|
||||||
etag: string
|
|
||||||
id: string
|
|
||||||
key: string
|
|
||||||
owner_id: string | null
|
|
||||||
part_number: number
|
|
||||||
size: number
|
|
||||||
upload_id: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
Insert: {
|
|
||||||
bucket_id: string
|
|
||||||
created_at?: string
|
|
||||||
etag: string
|
|
||||||
id?: string
|
|
||||||
key: string
|
|
||||||
owner_id?: string | null
|
|
||||||
part_number: number
|
|
||||||
size?: number
|
|
||||||
upload_id: string
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
Update: {
|
|
||||||
bucket_id?: string
|
|
||||||
created_at?: string
|
|
||||||
etag?: string
|
|
||||||
id?: string
|
|
||||||
key?: string
|
|
||||||
owner_id?: string | null
|
|
||||||
part_number?: number
|
|
||||||
size?: number
|
|
||||||
upload_id?: string
|
|
||||||
version?: string
|
|
||||||
}
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "s3_multipart_uploads_parts_bucket_id_fkey"
|
|
||||||
columns: ["bucket_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "buckets"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: "s3_multipart_uploads_parts_upload_id_fkey"
|
|
||||||
columns: ["upload_id"]
|
|
||||||
isOneToOne: false
|
|
||||||
referencedRelation: "s3_multipart_uploads"
|
|
||||||
referencedColumns: ["id"]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Views: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Functions: {
|
|
||||||
can_insert_object: {
|
|
||||||
Args: {
|
|
||||||
bucketid: string
|
|
||||||
name: string
|
|
||||||
owner: string
|
|
||||||
metadata: Json
|
|
||||||
}
|
|
||||||
Returns: undefined
|
|
||||||
}
|
|
||||||
extension: {
|
|
||||||
Args: {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Returns: string
|
|
||||||
}
|
|
||||||
filename: {
|
|
||||||
Args: {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Returns: string
|
|
||||||
}
|
|
||||||
foldername: {
|
|
||||||
Args: {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
Returns: string[]
|
|
||||||
}
|
|
||||||
get_size_by_bucket: {
|
|
||||||
Args: Record<PropertyKey, never>
|
|
||||||
Returns: {
|
|
||||||
size: number
|
|
||||||
bucket_id: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
list_multipart_uploads_with_delimiter: {
|
|
||||||
Args: {
|
|
||||||
bucket_id: string
|
|
||||||
prefix_param: string
|
|
||||||
delimiter_param: string
|
|
||||||
max_keys?: number
|
|
||||||
next_key_token?: string
|
|
||||||
next_upload_token?: string
|
|
||||||
}
|
|
||||||
Returns: {
|
|
||||||
key: string
|
|
||||||
id: string
|
|
||||||
created_at: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
list_objects_with_delimiter: {
|
|
||||||
Args: {
|
|
||||||
bucket_id: string
|
|
||||||
prefix_param: string
|
|
||||||
delimiter_param: string
|
|
||||||
max_keys?: number
|
|
||||||
start_after?: string
|
|
||||||
next_token?: string
|
|
||||||
}
|
|
||||||
Returns: {
|
|
||||||
name: string
|
|
||||||
id: string
|
|
||||||
metadata: Json
|
|
||||||
updated_at: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
operation: {
|
|
||||||
Args: Record<PropertyKey, never>
|
|
||||||
Returns: string
|
|
||||||
}
|
|
||||||
search: {
|
|
||||||
Args: {
|
|
||||||
prefix: string
|
|
||||||
bucketname: string
|
|
||||||
limits?: number
|
|
||||||
levels?: number
|
|
||||||
offsets?: number
|
|
||||||
search?: string
|
|
||||||
sortcolumn?: string
|
|
||||||
sortorder?: string
|
|
||||||
}
|
|
||||||
Returns: {
|
|
||||||
name: string
|
|
||||||
id: string
|
|
||||||
updated_at: string
|
|
||||||
created_at: string
|
|
||||||
last_accessed_at: string
|
|
||||||
metadata: Json
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Enums: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
}
|
||||||
CompositeTypes: {
|
CompositeTypes: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -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,44 @@
|
|||||||
|
import { createLogger, transports, format } from 'winston';
|
||||||
|
import { WinstonTransport as AxiomTransport } from '@axiomhq/winston';
|
||||||
|
import {
|
||||||
|
VITE_AXIOM_DATASET,
|
||||||
|
VITE_AXIOM_TOKEN,
|
||||||
|
VITE_LOGGER_SERVICE_NAME
|
||||||
|
} from '$env/static/private';
|
||||||
|
|
||||||
|
const axiomTransport = new AxiomTransport({
|
||||||
|
dataset: VITE_AXIOM_DATASET,
|
||||||
|
token: VITE_AXIOM_TOKEN
|
||||||
|
});
|
||||||
|
|
||||||
|
const logger = createLogger({
|
||||||
|
level: 'info',
|
||||||
|
defaultMeta: { service: VITE_LOGGER_SERVICE_NAME }, // 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import Stripe from 'stripe'
|
||||||
|
import { STRIPE_SECRET_KEY } from '$env/static/private'
|
||||||
|
|
||||||
|
export const stripe = new Stripe(STRIPE_SECRET_KEY)
|
||||||
+469
-26
@@ -1,8 +1,11 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
import stripe from 'stripe';
|
import stripe from 'stripe';
|
||||||
import type { Database, Tables, TablesInsert } from '$types/supabase';
|
import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
|
||||||
import { PUBLIC_SUPABASE_URL, PUBLIC_STRIPE_SECRET_KEY } 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 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.
|
||||||
@@ -10,8 +13,6 @@ const toDateTime = (secs: number) => {
|
|||||||
return t;
|
return t;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
|
||||||
|
|
||||||
type Product = Tables<'products'>;
|
type Product = Tables<'products'>;
|
||||||
type Price = Tables<'prices'>;
|
type Price = Tables<'prices'>;
|
||||||
|
|
||||||
@@ -31,13 +32,16 @@ const upsertProductRecord = async (product: stripe.Product) => {
|
|||||||
active: product.active,
|
active: product.active,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
description: product.description ?? null,
|
description: product.description ?? null,
|
||||||
image: product.images?.[0] ?? null,
|
features: product.marketing_features.map(({ name }) => name || '').filter((name) => !!name),
|
||||||
metadata: product.metadata
|
images: product.images ?? null,
|
||||||
|
metadata: product.metadata,
|
||||||
|
created_at: new Date(product.created * 1000).toISOString(),
|
||||||
|
updated_at: new Date(product.updated * 1000).toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -46,6 +50,8 @@ const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries
|
|||||||
product_id: typeof price.product === 'string' ? price.product : '',
|
product_id: typeof price.product === 'string' ? price.product : '',
|
||||||
active: price.active,
|
active: price.active,
|
||||||
currency: price.currency,
|
currency: price.currency,
|
||||||
|
description: price.nickname ?? null,
|
||||||
|
metadata: price.metadata,
|
||||||
type: price.type,
|
type: price.type,
|
||||||
unit_amount: price.unit_amount ?? null,
|
unit_amount: price.unit_amount ?? null,
|
||||||
interval: price.recurring?.interval ?? null,
|
interval: price.recurring?.interval ?? null,
|
||||||
@@ -57,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 {
|
||||||
@@ -68,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}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,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) => {
|
||||||
@@ -106,6 +112,31 @@ const createCustomerInStripe = async (uuid: string, email: string) => {
|
|||||||
return newCustomer.id;
|
return newCustomer.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStripeCustomerId = async (email: string, uuid: string) => {
|
||||||
|
const { data: existingSupabaseCustomer, error: queryError } = await supabaseAdmin
|
||||||
|
.from('customers')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', uuid)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (queryError) {
|
||||||
|
throw new Error(`Supabase customer lookup failed: ${queryError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripeCustomerId: string | undefined;
|
||||||
|
if (existingSupabaseCustomer?.stripe_customer_id) {
|
||||||
|
const existingStripeCustomer = await stripeClient.customers.retrieve(
|
||||||
|
existingSupabaseCustomer.stripe_customer_id
|
||||||
|
);
|
||||||
|
stripeCustomerId = existingStripeCustomer.id;
|
||||||
|
} else {
|
||||||
|
const stripeCustomers = await stripeClient.customers.list({ email: email });
|
||||||
|
stripeCustomerId = stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripeCustomerId;
|
||||||
|
};
|
||||||
|
|
||||||
const createOrRetrieveCustomer = async ({ email, uuid }: { email: string; uuid: string }) => {
|
const createOrRetrieveCustomer = async ({ email, uuid }: { email: string; uuid: string }) => {
|
||||||
// Check if the customer already exists in Supabase
|
// Check if the customer already exists in Supabase
|
||||||
const { data: existingSupabaseCustomer, error: queryError } = await supabaseAdmin
|
const { data: existingSupabaseCustomer, error: queryError } = await supabaseAdmin
|
||||||
@@ -207,10 +238,9 @@ const manageSubscriptionStatusChange = async (
|
|||||||
user_id: uuid,
|
user_id: uuid,
|
||||||
metadata: subscription.metadata,
|
metadata: subscription.metadata,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
|
product_id: subscription.items.data[0].price.product as string,
|
||||||
price_id: subscription.items.data[0].price.id,
|
price_id: subscription.items.data[0].price.id,
|
||||||
//TODO check quantity on subscription
|
quantity: 1, //subscription.quantity,
|
||||||
|
|
||||||
quantity: subscription.quantity,
|
|
||||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||||
cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null,
|
cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null,
|
||||||
canceled_at: subscription.canceled_at
|
canceled_at: subscription.canceled_at
|
||||||
@@ -226,19 +256,419 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
// For a new subscription copy the billing details to the customer object.
|
logger.info(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
|
||||||
// NOTE: This is a costly operation and should happen at the very end.
|
|
||||||
if (createAction && subscription.default_payment_method && uuid)
|
// // For a new subscription copy the billing details to the customer object.
|
||||||
await copyBillingDetailsToCustomer(
|
// // NOTE: This is a costly operation and should happen at the very end.
|
||||||
uuid,
|
// if (createAction && subscription.default_payment_method && uuid) {
|
||||||
subscription.default_payment_method as stripe.PaymentMethod
|
// await copyBillingDetailsToCustomer(
|
||||||
|
// uuid,
|
||||||
|
// subscription.default_payment_method as stripe.PaymentMethod
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordProductPurchase = async (userId: string, productId: string, priceId: string) => {
|
||||||
|
logger.info(
|
||||||
|
`Recording product purchase for user [${userId}] and product [${productId}] with price [${priceId}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabaseAdmin
|
||||||
|
.from('purchases')
|
||||||
|
.insert([{ user_id: userId, product_id: productId, price_id: priceId }]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Failed to record product purchase: ${error.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Product purchased recorded for user [${userId}] and product [${productId}]`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasProductAccess = async (userId: string, productId: string) => {
|
||||||
|
// Check if user has purchased the product
|
||||||
|
try {
|
||||||
|
const { data: purchases, error: purchaseError } = await supabaseAdmin
|
||||||
|
.from('purchases')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('product_id', productId);
|
||||||
|
|
||||||
|
if (purchaseError) throw new Error(purchaseError.message);
|
||||||
|
|
||||||
|
if (purchases && purchases.length > 0) {
|
||||||
|
// User has purchased the product
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has an active subscription for the product
|
||||||
|
try {
|
||||||
|
const { data: subscriptions, error: subError } = await supabaseAdmin
|
||||||
|
.from('subscriptions')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('product_id', productId)
|
||||||
|
.in('status', ['active', 'trialing']);
|
||||||
|
|
||||||
|
if (subError) throw new Error(subError.message);
|
||||||
|
|
||||||
|
if (subscriptions && subscriptions.length > 0) {
|
||||||
|
// User has an active subscription
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateInput = (userId: string, credits: number, description: string) => {
|
||||||
|
if (!userId) throw new Error('User ID is required');
|
||||||
|
if (credits <= 0) throw new Error('Credits must be greater than zero');
|
||||||
|
if (!description) throw new Error('Description is required');
|
||||||
|
};
|
||||||
|
|
||||||
|
const logCreditTransaction = async (userId: string, creditsChange: number, description: string) => {
|
||||||
|
const { error } = await supabaseAdmin.from('credit_transactions').insert({
|
||||||
|
user_id: userId,
|
||||||
|
credits_change: creditsChange,
|
||||||
|
description,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Error logging credit transaction: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUserCredits = async (userId: string, creditsChange: number, description: string) => {
|
||||||
|
validateInput(userId, Math.abs(creditsChange), description);
|
||||||
|
|
||||||
|
const { data: creditData, error: fetchError } = await supabaseAdmin
|
||||||
|
.from('user_credits')
|
||||||
|
.select('credits_remaining, last_updated')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
throw new Error(`Error fetching credits: ${fetchError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCreditTotal = (creditData?.credits_remaining || 0) + creditsChange;
|
||||||
|
|
||||||
|
if (newCreditTotal < 0) {
|
||||||
|
throw new Error('Insufficient credits');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: upsertError } = await supabaseAdmin
|
||||||
|
.from('user_credits')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
credits_remaining: newCreditTotal,
|
||||||
|
last_updated: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onConflict: 'user_id'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.eq('last_updated', creditData?.last_updated); // Optimistic concurrency control
|
||||||
|
|
||||||
|
if (upsertError) {
|
||||||
|
throw new Error(`Error updating credits: ${upsertError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await logCreditTransaction(userId, creditsChange, description);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`${creditsChange > 0 ? 'Added' : 'Deducted'} ${Math.abs(creditsChange)} credits to user [${userId}]`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCredits = async (userId: string, creditsToAdd: number, description: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserCredits(userId, creditsToAdd, description);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deductCredits = async (userId: string, creditsToDeduct: number, description: string) => {
|
||||||
|
try {
|
||||||
|
await updateUserCredits(userId, -creditsToDeduct, description);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserCredits = async (userId: string) => {
|
||||||
|
if (!userId) throw new Error('User ID is required');
|
||||||
|
|
||||||
|
// Fetch the user's credits from the user_credits table
|
||||||
|
const { data: creditData, error } = await supabaseAdmin
|
||||||
|
.from('user_credits')
|
||||||
|
.select('credits_remaining')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error && error.code !== 'PGRST116') {
|
||||||
|
// Code 'PGRST116' represents no rows found
|
||||||
|
throw new Error(`Error fetching credits: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!creditData) {
|
||||||
|
// If no row exists in the user_credits table, insert a new row for the user
|
||||||
|
const { error: insertError } = await supabaseAdmin.from('user_credits').insert({
|
||||||
|
user_id: userId,
|
||||||
|
credits_remaining: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
throw new Error(`Error creating user credits: ${insertError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 & {
|
||||||
|
prices: Price[];
|
||||||
|
actualPrice?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActiveProductsWithPrices = async (limit = 10, offset = 0) => {
|
||||||
|
const {
|
||||||
|
data: products,
|
||||||
|
error,
|
||||||
|
count
|
||||||
|
} = await supabaseAdmin
|
||||||
|
.from('products')
|
||||||
|
.select('*, prices(*)', { count: 'exact' })
|
||||||
|
.eq('active', true)
|
||||||
|
.order('name', { ascending: true })
|
||||||
|
.range(offset, offset + limit - 1);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Error fetching products: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const product of products as ProductWithPrices[]) {
|
||||||
|
if (product.prices && product.prices.length > 0) {
|
||||||
|
// Assuming the default price is the first one
|
||||||
|
const defaultPrice = product.prices[0];
|
||||||
|
product.actualPrice = defaultPrice.unit_amount! / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { products: products as ProductWithPrices[], count };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProductById = async (productId: string) => {
|
||||||
|
const { data: product, error } = await supabaseAdmin
|
||||||
|
.from('products')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', productId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Error fetching product: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
perPage: number,
|
||||||
|
startingAfter: string | undefined = undefined,
|
||||||
|
endingBefore: string | undefined = undefined
|
||||||
|
) => {
|
||||||
|
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) {
|
||||||
|
logger.error('Error retrieving user data:', error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 params: stripe.ChargeListParams = {
|
||||||
|
customer: customerId,
|
||||||
|
limit: perPage
|
||||||
|
};
|
||||||
|
|
||||||
|
if (startingAfter) params.starting_after = startingAfter;
|
||||||
|
if (endingBefore) params.ending_before = endingBefore;
|
||||||
|
|
||||||
|
const charges = await stripeClient.charges.list(params);
|
||||||
|
|
||||||
|
const hasNextPage = charges.has_more;
|
||||||
|
|
||||||
|
// Step 3: Format transaction data
|
||||||
|
const transactions = charges.data.map((charge) => ({
|
||||||
|
id: charge.id,
|
||||||
|
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, hasNextPage };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('An error occurred while retrieving transactions:', error);
|
||||||
|
throw new Error('Could not retrieve transactions');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -247,5 +677,18 @@ export {
|
|||||||
deleteProductRecord,
|
deleteProductRecord,
|
||||||
deletePriceRecord,
|
deletePriceRecord,
|
||||||
createOrRetrieveCustomer,
|
createOrRetrieveCustomer,
|
||||||
manageSubscriptionStatusChange
|
manageSubscriptionStatusChange,
|
||||||
|
recordProductPurchase,
|
||||||
|
hasProductAccess,
|
||||||
|
addCredits,
|
||||||
|
deductCredits,
|
||||||
|
getUserCredits,
|
||||||
|
getActiveProductsWithPrices,
|
||||||
|
getProductById,
|
||||||
|
upsertCustomerToSupabase,
|
||||||
|
getStripeCustomerId,
|
||||||
|
getUserSubscriptions,
|
||||||
|
getUserTransactions,
|
||||||
|
requestAccountDeletion,
|
||||||
|
deleteAccount
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export async function signOut(supabase: SupabaseClient, callback: () => void): Promise<void> {
|
||||||
|
// TODO - #61 use the error from the response
|
||||||
|
const { error } = await supabase.auth.signOut().then(callback);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const transactionsPerPage = 5;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: user } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select(`name`)
|
||||||
|
.eq('id', session.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const subscriptions = await getUserSubscriptions(session.user.id);
|
||||||
|
|
||||||
|
const transactionsData = await getUserTransactions(session.user.id, transactionsPerPage);
|
||||||
|
|
||||||
|
const transactions = transactionsData?.transactions ?? [];
|
||||||
|
const firstTransactionId = transactions.length > 0 ? transactions[0].id : undefined;
|
||||||
|
const hasMoreThanOnePage = transactionsData?.hasNextPage ?? false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
subscriptions,
|
||||||
|
transactions,
|
||||||
|
firstTransactionId,
|
||||||
|
hasMoreThanOnePage,
|
||||||
|
pageSize: transactionsPerPage
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 } }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return fail(401, { name });
|
||||||
|
}
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({
|
||||||
|
name: name
|
||||||
|
})
|
||||||
|
.eq('id', session?.user.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.error('Failed to update user name', {
|
||||||
|
userId: session?.user.id,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return fail(500, {
|
||||||
|
name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
};
|
||||||
|
},
|
||||||
|
paginate: async ({ request, locals: { safeGetSession } }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const pageSize = transactionsPerPage;
|
||||||
|
|
||||||
|
let startingAfter = formData.get('startingAfter')?.toString() || undefined;
|
||||||
|
if (startingAfter === 'undefined') {
|
||||||
|
startingAfter = undefined;
|
||||||
|
}
|
||||||
|
let endingBefore = formData.get('endingBefore')?.toString() || undefined;
|
||||||
|
if (endingBefore === 'undefined') {
|
||||||
|
endingBefore = undefined;
|
||||||
|
}
|
||||||
|
const firstTransactionId = formData.get('firstTransactionId')?.toString();
|
||||||
|
const hasMoreThanOnePage = formData.get('hasMoreThanOnePage') === 'true';
|
||||||
|
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
if (!session) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { transactions, hasNextPage } = await getUserTransactions(
|
||||||
|
session.user.id,
|
||||||
|
pageSize,
|
||||||
|
startingAfter,
|
||||||
|
endingBefore
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFirstPage = transactions.length > 0 && transactions[0].id === firstTransactionId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactions: JSON.stringify(transactions),
|
||||||
|
hasNextPage: hasNextPage || (isFirstPage && hasMoreThanOnePage),
|
||||||
|
isFirstPage
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch transactions', { userId: session.user.id, error });
|
||||||
|
return fail(500, { error: 'Failed to load transactions' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
export let form;
|
||||||
|
|
||||||
|
let { session, user, subscriptions, transactions, firstTransactionId, hasMoreThanOnePage } = data;
|
||||||
|
$: ({ session, user, transactions, firstTransactionId, hasMoreThanOnePage } = data);
|
||||||
|
|
||||||
|
let profileForm: HTMLFormElement;
|
||||||
|
let loading = false;
|
||||||
|
let name: string = user?.name ?? '';
|
||||||
|
|
||||||
|
const handleSubmit: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
return async () => {
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignOut: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
loading = false;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let startingAfter: string | undefined = undefined;
|
||||||
|
let endingBefore: string | undefined = undefined;
|
||||||
|
let hasNextPage = hasMoreThanOnePage;
|
||||||
|
let hasPreviousPage = false;
|
||||||
|
|
||||||
|
const handlePaginationSubmit: SubmitFunction = () => {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
return async ({ result }) => {
|
||||||
|
transactions = JSON.parse(result.data.transactions);
|
||||||
|
hasNextPage = result.data.hasNextPage;
|
||||||
|
hasPreviousPage = !result.data.isFirstPage;
|
||||||
|
loading = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="account-page max-w-4xl mx-auto p-6 bg-base-100 rounded-lg shadow-md space-y-6 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- Flex container for two columns -->
|
||||||
|
<div class="flex flex-col lg:flex-row gap-6">
|
||||||
|
<!-- Left Column: Account Overview, Subscriptions, and Billing History -->
|
||||||
|
<div class="flex-1 space-y-6">
|
||||||
|
<!-- Account Overview -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Account Overview</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Billing History</h2>
|
||||||
|
|
||||||
|
{#if transactions && transactions.length > 0}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{#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}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="text-gray-600">No transactions found.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Pagination Form -->
|
||||||
|
<form method="post" action="?/paginate" use:enhance={handlePaginationSubmit}>
|
||||||
|
<div
|
||||||
|
class="flex justify-center items-center space-x-2 mt-4"
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Transactions pagination"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="startingAfter" value={startingAfter} />
|
||||||
|
<input type="hidden" name="endingBefore" value={endingBefore} />
|
||||||
|
<input type="hidden" name="firstTransactionId" value={firstTransactionId} />
|
||||||
|
<input type="hidden" name="hasMoreThanOnePage" value={hasMoreThanOnePage} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="direction"
|
||||||
|
value="previous"
|
||||||
|
class="btn btn-outline"
|
||||||
|
disabled={!hasPreviousPage || loading}
|
||||||
|
aria-label="View previous page of transactions"
|
||||||
|
on:click={() => {
|
||||||
|
if (transactions.length) {
|
||||||
|
endingBefore = transactions[0].id;
|
||||||
|
startingAfter = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="direction"
|
||||||
|
value="next"
|
||||||
|
class="btn btn-outline"
|
||||||
|
disabled={!hasNextPage || loading}
|
||||||
|
aria-label="View next page of transactions"
|
||||||
|
on:click={() => {
|
||||||
|
if (transactions.length) {
|
||||||
|
startingAfter = transactions[transactions.length - 1].id;
|
||||||
|
endingBefore = undefined;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Notification Preferences and Feedback -->
|
||||||
|
<div class="flex-none w-full lg:w-64 space-y-6">
|
||||||
|
<!-- Notification Preferences -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2 class="text-xl font-semibold">Notification Preferences</h2>
|
||||||
|
<form method="post" action="?/update_notifications" class="space-y-2">
|
||||||
|
<label class="flex items-center space-x-3">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-primary" />
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Feedback Section -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<form method="post" action="?/delete" use:enhance={handleAccountDeletion}>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-error w-full" disabled={loading}>Delete My Account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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,164 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Chart, { type ChartData } from 'chart.js/auto';
|
||||||
|
import { DataTable } from 'simple-datatables';
|
||||||
|
import 'simple-datatables/dist/style.css';
|
||||||
|
|
||||||
|
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>
|
||||||
+14
-158
@@ -1,34 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.postcss';
|
import '../app.postcss';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { isBrowser } from '@supabase/ssr';
|
import { isBrowser } from '@supabase/ssr';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
|
||||||
import { invalidate } from '$app/navigation';
|
import { invalidate } from '$app/navigation';
|
||||||
import SignIn from '$lib/components/SignIn.svelte';
|
|
||||||
import SignOut from '$lib/components/SignOut.svelte';
|
|
||||||
import CookieConsent from '$lib/components/CookieConsent.svelte';
|
import CookieConsent from '$lib/components/CookieConsent.svelte';
|
||||||
import NavLinks from '$lib/components/NavLinks.svelte';
|
import Nav from '$lib/components/navigation/Nav.svelte';
|
||||||
|
import Toast from '$lib/components/shared/Toast.svelte';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase, session } = data;
|
let { supabase, session } = data;
|
||||||
$: ({ supabase, session } = data);
|
$: ({ supabase, session } = data);
|
||||||
|
|
||||||
let localBasket: Basket;
|
|
||||||
const unsubscribeToBasket = basket.subscribe((value) => {
|
|
||||||
localBasket = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
let localGeneral: any;
|
|
||||||
const unsubscribeToGeneralStore = general.subscribe((value) => {
|
|
||||||
localGeneral = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
unsubscribeToBasket();
|
|
||||||
unsubscribeToGeneralStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
let cookiesAccepted = false;
|
let cookiesAccepted = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -63,96 +45,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<nav class="navbar bg-primary text-primary-content">
|
<Nav {session} {supabase} />
|
||||||
<div class="navbar-start">
|
|
||||||
<div class="dropdown">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 6h16M4 12h8m-8 6h16"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<ul
|
|
||||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 text-base-content rounded-box w-52"
|
|
||||||
>
|
|
||||||
<NavLinks isMobile={true} />
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<a href="/" class="btn btn-ghost text-xl">SvelteKit SaaS Boilerplate</a>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-center hidden lg:flex">
|
|
||||||
<ul class="menu menu-horizontal px-1">
|
|
||||||
<NavLinks isMobile={false} />
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-end">
|
|
||||||
<a href="/checkout" class="btn btn-ghost btn-circle">
|
|
||||||
<div class="indicator">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
<span class="badge badge-sm indicator-item">{localBasket.items.length}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
|
||||||
<div class="w-10 rounded-full">
|
|
||||||
{#if session?.user}
|
|
||||||
<div class="user-circle text-primary-content border-primary-content">
|
|
||||||
{session?.user.email[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if session?.user}
|
|
||||||
<ul
|
|
||||||
tabindex="-1"
|
|
||||||
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<a href="/account"> Account </a>
|
|
||||||
</li>
|
|
||||||
<li><a href="/settings">Settings</a></li>
|
|
||||||
<li><SignOut {supabase} /></li>
|
|
||||||
</ul>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 text-base-content rounded-box min-w-[13rem] w-auto"
|
|
||||||
>
|
|
||||||
<SignIn {supabase} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -176,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
|
||||||
@@ -218,52 +115,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div
|
<Toast />
|
||||||
id={localGeneral.toastType == 'success' ? 'success-toast' : 'error-toast'}
|
|
||||||
role="alert"
|
|
||||||
class="alert {localGeneral.toastType == 'success'
|
|
||||||
? 'alert-success'
|
|
||||||
: 'alert-error'} fixed top-20 right-3 max-w-52 text-white {localGeneral.hideToast
|
|
||||||
? 'hidden'
|
|
||||||
: ''}"
|
|
||||||
>
|
|
||||||
{#if localGeneral.toastType == 'success'}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="stroke-current shrink-0 h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
><path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="stroke-current shrink-0 h-6 w-6"
|
|
||||||
viewBox="0 0 32 32"
|
|
||||||
xml:space="preserve"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
><g
|
|
||||||
><g id="Error_1_"
|
|
||||||
><g id="Error">
|
|
||||||
<circle cx="16" cy="16" id="BG" r="16" style="fill:#E6E6E6;" />
|
|
||||||
<path
|
|
||||||
d="M14.5,25h3v-3h-3V25z M14.5,6v13h3V6H14.5z"
|
|
||||||
id="Exclamatory_x5F_Sign"
|
|
||||||
style="fill:#D72828;"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</g></g
|
|
||||||
></svg
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span>{localGeneral.toastMessage == '' ? 'Added to basket' : localGeneral.toastMessage}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style scoped lang="postcss">
|
<style scoped lang="postcss">
|
||||||
.user-circle {
|
.user-circle {
|
||||||
@@ -277,4 +129,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 20px; /* Adjust font size as needed */
|
font-size: 20px; /* Adjust font size as needed */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: calc(100dvh - 68px - 68px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+17
-9
@@ -1,21 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SignUp from '$lib/components/SignUp.svelte';
|
import MagicLink from '$lib/components/MagicLink.svelte';
|
||||||
|
|
||||||
export let data;
|
export let data;
|
||||||
let { supabase, session } = data;
|
let { supabase, session } = data;
|
||||||
$: ({ supabase, session } = data);
|
$: ({ supabase, session } = data);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="hero bg-base-100 my-36">
|
{#if session !== null}
|
||||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
<div class="hero bg-base-100 my-36">
|
||||||
<div class="text-center lg:text-left">
|
Welcome back {session.user.email}
|
||||||
<h1 class="text-5xl font-bold">Sign up for this amazing SaaS right now - don't miss out!</h1>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
<div id="sign-up" class="card shrink-0 w-full max-w-sm shadow-2xl">
|
<div class="hero bg-base-100 my-36">
|
||||||
<SignUp {supabase} />
|
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||||
|
<div class="text-center lg:text-left">
|
||||||
|
<h1 class="text-5xl font-bold">
|
||||||
|
Sign up for this amazing SaaS right now - don't miss out!
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div id="sign-up" class="card shrink-0 w-full max-w-sm shadow-2xl">
|
||||||
|
<MagicLink {supabase} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10"
|
class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10"
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect(303, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select(`username, full_name`)
|
|
||||||
.eq('id', session.user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
return { session, profile };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
update: async ({ request, locals: { supabase, safeGetSession } }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const fullName = formData.get('fullName') as string;
|
|
||||||
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
|
|
||||||
const { error } = await supabase.from('profiles').upsert({
|
|
||||||
id: session?.user.id,
|
|
||||||
full_name: fullName,
|
|
||||||
username: session?.user.email,
|
|
||||||
updated_at: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return fail(500, {
|
|
||||||
fullName,
|
|
||||||
username: session?.user.email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fullName,
|
|
||||||
username: session?.user.email
|
|
||||||
};
|
|
||||||
},
|
|
||||||
signout: async ({ locals: { supabase, safeGetSession } }) => {
|
|
||||||
const { session } = await safeGetSession();
|
|
||||||
if (session) {
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
redirect(303, '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export let data;
|
|
||||||
export let form;
|
|
||||||
|
|
||||||
let { session, supabase, profile } = data;
|
|
||||||
$: ({ session, supabase, profile } = data);
|
|
||||||
|
|
||||||
let profileForm: HTMLFormElement;
|
|
||||||
let loading = false;
|
|
||||||
let fullName: string = profile?.full_name ?? '';
|
|
||||||
|
|
||||||
const handleSubmit: SubmitFunction = () => {
|
|
||||||
loading = true;
|
|
||||||
return async () => {
|
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignOut: SubmitFunction = () => {
|
|
||||||
loading = true;
|
|
||||||
return async ({ update }) => {
|
|
||||||
loading = false;
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="form-widget">
|
|
||||||
<form
|
|
||||||
class="form-widget"
|
|
||||||
method="post"
|
|
||||||
action="?/update"
|
|
||||||
use:enhance={handleSubmit}
|
|
||||||
bind:this={profileForm}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label for="email">Username</label>
|
|
||||||
<input id="email" type="text" value={session.user.email} disabled />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="fullName">Full Name</label>
|
|
||||||
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="submit"
|
|
||||||
class="button block primary"
|
|
||||||
value={loading ? 'Loading...' : 'Update'}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="post" action="?/signout" use:enhance={handleSignOut}>
|
|
||||||
<div>
|
|
||||||
<button class="button block" disabled={loading}>Sign Out</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
import { type RequestHandler, redirect } from '@sveltejs/kit';
|
import { type RequestHandler, redirect } from '@sveltejs/kit';
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import stripe from 'stripe';
|
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
// Create a checkout session
|
// Create a checkout session
|
||||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
// If no session is found, the user isn't authenticated, so block the action
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const userEmail = session.user.email;
|
||||||
|
|
||||||
const formData = new URLSearchParams(await request.text());
|
const formData = new URLSearchParams(await request.text());
|
||||||
const items: { priceId: string; quantity: number }[] = [];
|
const items: { priceId: string; quantity: number }[] = [];
|
||||||
|
|
||||||
@@ -19,14 +29,37 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
quantity: item.quantity
|
quantity: item.quantity
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const session = await stripeClient.checkout.sessions.create({
|
let stripeCustomerId = null;
|
||||||
|
|
||||||
|
// Get the existing Stripe customer ID from Supabase, if it exists
|
||||||
|
try {
|
||||||
|
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching Stripe customer ID:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeCheckoutSessionObject = {
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
mode: 'payment',
|
mode: 'payment',
|
||||||
success_url: `${request.headers.get('origin')}/checkout/success`,
|
success_url: `${request.headers.get('origin')}/checkout/success`,
|
||||||
cancel_url: `${request.headers.get('origin')}/checkout/cancelled`
|
cancel_url: `${request.headers.get('origin')}/checkout/cancelled`,
|
||||||
});
|
metadata: {
|
||||||
|
userId: userId
|
||||||
|
}
|
||||||
|
} as Stripe.Checkout.SessionCreateParams;
|
||||||
|
|
||||||
|
if (stripeCustomerId) {
|
||||||
|
// If the user has a Stripe customer ID, attach it to the checkout session
|
||||||
|
stripeCheckoutSessionObject.customer = stripeCustomerId;
|
||||||
|
} else {
|
||||||
|
// If the user doesn't have a Stripe customer ID, create a new customer
|
||||||
|
stripeCheckoutSessionObject.customer_creation = 'always';
|
||||||
|
stripeCheckoutSessionObject.customer_email = userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutSession = await stripeClient.checkout.sessions.create(stripeCheckoutSessionObject);
|
||||||
|
|
||||||
// Store the checkout session.id for access from the frontend
|
// Store the checkout session.id for access from the frontend
|
||||||
cookies.set('checkout_session_id', session.id, { path: '/' });
|
cookies.set('checkout_session_id', checkoutSession.id, { path: '/' });
|
||||||
return redirect(303, session.url as string);
|
return redirect(303, checkoutSession.url as string);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
|
||||||
import stripe from 'stripe';
|
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ cookies }) => {
|
export const GET: RequestHandler = async ({ cookies }) => {
|
||||||
const sessionId = cookies.get('checkout_session_id');
|
const sessionId = cookies.get('checkout_session_id');
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { type RequestHandler, redirect } from '@sveltejs/kit';
|
||||||
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
|
import { getStripeCustomerId } from '$lib/utils/supabase/admin';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
|
import logger from '$lib/utils/logger/logger';
|
||||||
|
|
||||||
|
// Create a subscription checkout session
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
// If no session is found, the user isn't authenticated, so block the action
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const userEmail = session.user.email;
|
||||||
|
|
||||||
|
// Get the price ID from the request form data
|
||||||
|
const formData = await request.formData();
|
||||||
|
const priceId = formData.get('priceId');
|
||||||
|
|
||||||
|
if (!priceId) {
|
||||||
|
return new Response('Price ID is required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripeCustomerId = null;
|
||||||
|
|
||||||
|
// Get the existing Stripe customer ID from Supabase, if it exists
|
||||||
|
try {
|
||||||
|
stripeCustomerId = await getStripeCustomerId(userEmail!, userId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching Stripe customer ID:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeCheckoutSessionObject = {
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: priceId,
|
||||||
|
quantity: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
mode: 'subscription',
|
||||||
|
success_url: `${request.headers.get('origin')}/checkout/success`,
|
||||||
|
cancel_url: `${request.headers.get('origin')}/checkout/cancelled`,
|
||||||
|
metadata: {
|
||||||
|
userId: userId
|
||||||
|
}
|
||||||
|
} as Stripe.Checkout.SessionCreateParams;
|
||||||
|
|
||||||
|
if (stripeCustomerId) {
|
||||||
|
// If the user has a Stripe customer ID, attach it to the checkout session
|
||||||
|
stripeCheckoutSessionObject.customer = stripeCustomerId;
|
||||||
|
} else {
|
||||||
|
// If the user doesn't have a Stripe customer ID, create a new customer with their email
|
||||||
|
stripeCheckoutSessionObject.customer_email = userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a subscription checkout session with Stripe
|
||||||
|
const checkoutSession = await stripeClient.checkout.sessions.create(stripeCheckoutSessionObject);
|
||||||
|
|
||||||
|
// Store the checkout session.id for access from the frontend
|
||||||
|
cookies.set('checkout_session_id', checkoutSession.id, { path: '/' });
|
||||||
|
return redirect(303, checkoutSession.url as string);
|
||||||
|
};
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { RequestHandler } from '@sveltejs/kit';
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import stripe from 'stripe';
|
import stripe from 'stripe';
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
import { STRIPE_ENDPOINT_SECRET } from '$env/static/private';
|
||||||
import { PUBLIC_STRIPE_ENDPOINT_SECRET } from '$env/static/public';
|
|
||||||
import {
|
import {
|
||||||
deletePriceRecord,
|
deletePriceRecord,
|
||||||
deleteProductRecord,
|
deleteProductRecord,
|
||||||
manageSubscriptionStatusChange,
|
manageSubscriptionStatusChange,
|
||||||
upsertPriceRecord,
|
upsertPriceRecord,
|
||||||
upsertProductRecord
|
upsertProductRecord,
|
||||||
|
recordProductPurchase
|
||||||
} from '$lib/utils/supabase/admin';
|
} from '$lib/utils/supabase/admin';
|
||||||
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
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();
|
||||||
@@ -23,15 +23,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
|
|
||||||
// Only verify the event if you have an endpoint secret defined.
|
// Only verify the event if you have an endpoint secret defined.
|
||||||
// Otherwise use the basic event deserialized with JSON.parse
|
// Otherwise use the basic event deserialized with JSON.parse
|
||||||
if (PUBLIC_STRIPE_ENDPOINT_SECRET && signature) {
|
if (STRIPE_ENDPOINT_SECRET && signature) {
|
||||||
try {
|
try {
|
||||||
event = stripeClient.webhooks.constructEvent(
|
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
|
||||||
body,
|
|
||||||
signature,
|
|
||||||
PUBLIC_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: {}
|
||||||
@@ -44,6 +40,9 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
case 'product.updated':
|
case 'product.updated':
|
||||||
await upsertProductRecord(event.data.object as stripe.Product);
|
await upsertProductRecord(event.data.object as stripe.Product);
|
||||||
break;
|
break;
|
||||||
|
case 'product.deleted':
|
||||||
|
await deleteProductRecord(event.data.object as stripe.Product);
|
||||||
|
break;
|
||||||
case 'price.created':
|
case 'price.created':
|
||||||
case 'price.updated':
|
case 'price.updated':
|
||||||
await upsertPriceRecord(event.data.object as stripe.Price);
|
await upsertPriceRecord(event.data.object as stripe.Price);
|
||||||
@@ -51,12 +50,11 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
case 'price.deleted':
|
case 'price.deleted':
|
||||||
await deletePriceRecord(event.data.object as stripe.Price);
|
await deletePriceRecord(event.data.object as stripe.Price);
|
||||||
break;
|
break;
|
||||||
case 'product.deleted':
|
|
||||||
await deleteProductRecord(event.data.object as stripe.Product);
|
|
||||||
break;
|
|
||||||
case 'customer.subscription.created':
|
case 'customer.subscription.created':
|
||||||
case 'customer.subscription.updated':
|
case 'customer.subscription.updated':
|
||||||
case 'customer.subscription.deleted': {
|
case 'customer.subscription.deleted':
|
||||||
|
case 'customer.subscription.paused':
|
||||||
|
case 'customer.subscription.resumed': {
|
||||||
const subscription = event.data.object as stripe.Subscription;
|
const subscription = event.data.object as stripe.Subscription;
|
||||||
await manageSubscriptionStatusChange(
|
await manageSubscriptionStatusChange(
|
||||||
subscription.id,
|
subscription.id,
|
||||||
@@ -67,6 +65,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
case 'checkout.session.completed': {
|
case 'checkout.session.completed': {
|
||||||
const checkoutSession = event.data.object as stripe.Checkout.Session;
|
const checkoutSession = event.data.object as stripe.Checkout.Session;
|
||||||
|
|
||||||
if (checkoutSession.mode === 'subscription') {
|
if (checkoutSession.mode === 'subscription') {
|
||||||
const subscriptionId = checkoutSession.subscription;
|
const subscriptionId = checkoutSession.subscription;
|
||||||
await manageSubscriptionStatusChange(
|
await manageSubscriptionStatusChange(
|
||||||
@@ -74,22 +73,44 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
checkoutSession.customer as string,
|
checkoutSession.customer as string,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
} else if (checkoutSession.mode === 'payment') {
|
||||||
|
// Fetch session with expanded line_items
|
||||||
|
const sessionWithLineItems = await stripeClient.checkout.sessions.retrieve(
|
||||||
|
checkoutSession.id,
|
||||||
|
{
|
||||||
|
expand: ['line_items']
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineItems = sessionWithLineItems.line_items?.data;
|
||||||
|
const userId = checkoutSession.metadata!.userId;
|
||||||
|
|
||||||
|
if (!lineItems) {
|
||||||
|
throw new Error('No line items found in session');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('No user ID found in session metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of lineItems ?? []) {
|
||||||
|
const productId = item.price?.product as string;
|
||||||
|
const priceId = item.price?.id as string;
|
||||||
|
|
||||||
|
if (productId && priceId) {
|
||||||
|
await recordProductPurchase(userId, productId, priceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.error(`Unhandled event type ${event.type}`);
|
logger.info(`Unhandled event type ${event.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a response to acknowledge receipt of the event
|
|
||||||
return new Response(JSON.stringify({ received: true }), {
|
|
||||||
status: 200
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`Webhook Error: ${err.message}`);
|
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
|
||||||
return {
|
return new Response(`Webhook Error: ${message}`, { status: 500 });
|
||||||
status: 400,
|
|
||||||
body: `Webhook Error: ${err.message}`
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return json({ received: true });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { redirect, type RequestHandler } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const { url, locals: { supabase } } = event;
|
||||||
|
const code = url.searchParams.get('code')
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
await supabase.auth.exchangeCodeForSession(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Create stripe customer and sync with supabase user
|
||||||
|
|
||||||
|
throw redirect(303, '/account')
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
|
||||||
|
|
||||||
// Access the supabase client from the layout data
|
|
||||||
export let supabase: any;
|
|
||||||
|
|
||||||
let newPassword = '';
|
|
||||||
|
|
||||||
async function resetPassword() {
|
|
||||||
if (newPassword === '') {
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Please enter a new password',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.updateUser({
|
|
||||||
password: newPassword
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating password:', error.message);
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Error updating password. Please try again.',
|
|
||||||
toastType: 'error'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
general.update((value) => {
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
hideToast: false,
|
|
||||||
toastMessage: 'Password updated successfully. Please sign in with your new password.',
|
|
||||||
toastType: 'success'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true, toastMessage: '', toastType: 'success' };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>Update Password</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<h1>Update Password</h1>
|
|
||||||
<form on:submit|preventDefault={resetPassword}>
|
|
||||||
<label for="newPassword">New Password</label>
|
|
||||||
<input type="password" id="newPassword" bind:value={newPassword} />
|
|
||||||
<button type="submit">Update Password</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('./$types').PageLoad} */
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const load = async ({ params, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
if (!session) {
|
||||||
|
throw redirect(303, '/login'); // Redirect unauthenticated users to login
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<script src="https://js.stripe.com/v3/" lang="ts">
|
<script src="https://js.stripe.com/v3/" lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
import BasketItem from '$lib/components/checkout/BasketItem.svelte';
|
import BasketItem from '$lib/components/checkout/BasketItem.svelte';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
let { supabase, session } = data;
|
||||||
|
$: ({ supabase, session } = data);
|
||||||
|
|
||||||
let localBasket: Basket;
|
let localBasket: Basket;
|
||||||
const unsubscribe = basket.subscribe((value) => {
|
const unsubscribe = basket.subscribe((value) => {
|
||||||
localBasket = value;
|
localBasket = value;
|
||||||
@@ -101,28 +105,32 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<button
|
{#if session?.user !== undefined}
|
||||||
type="submit"
|
<button
|
||||||
class="btn btn-primary"
|
type="submit"
|
||||||
disabled={localBasket.items && localBasket.items.length === 0}
|
class="btn btn-primary"
|
||||||
>Continue to Payment
|
disabled={localBasket.items && localBasket.items.length === 0}
|
||||||
<svg
|
>Continue to Payment
|
||||||
class="ml-2"
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="ml-2"
|
||||||
width="23"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
height="22"
|
width="23"
|
||||||
viewBox="0 0 23 22"
|
height="22"
|
||||||
fill="none"
|
viewBox="0 0 23 22"
|
||||||
>
|
fill="none"
|
||||||
<path
|
>
|
||||||
d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998"
|
<path
|
||||||
stroke="white"
|
d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998"
|
||||||
stroke-width="1.6"
|
stroke="white"
|
||||||
stroke-linecap="round"
|
stroke-width="1.6"
|
||||||
stroke-linejoin="round"
|
stroke-linecap="round"
|
||||||
/>
|
stroke-linejoin="round"
|
||||||
</svg>
|
/>
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p>You must sign in to checkout</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
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();
|
||||||
|
|
||||||
|
const response = await fetch('/api/checkout/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error('Failed to fetch checkout status');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseJson = await response.json();
|
||||||
|
const checkoutSession: Stripe.Checkout.Session = responseJson.checkoutSession;
|
||||||
|
const lineItems: Stripe.LineItem[] = responseJson.lineItems.data;
|
||||||
|
const paymentStatus: Stripe.Checkout.Session.PaymentStatus = responseJson.paymentStatus;
|
||||||
|
|
||||||
|
if (paymentStatus == 'unpaid') {
|
||||||
|
throw redirect(303, '/checkout/cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const lineItem of lineItems as LineItemForCheckout[]) {
|
||||||
|
const product = await getProductById(lineItem.price?.product.toString() ?? '');
|
||||||
|
lineItem.imgSrc = product.images ? product.images[0] : '';
|
||||||
|
lineItem.productDescription = product.description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session?.user.id) {
|
||||||
|
const stripeCustomerId = checkoutSession.customer as string;
|
||||||
|
await upsertCustomerToSupabase(session.user.id, stripeCustomerId);
|
||||||
|
} else {
|
||||||
|
logger.error('User ID is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
checkoutSession,
|
||||||
|
lineItems
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type LineItemForCheckout = Stripe.LineItem & {
|
||||||
|
imgSrc: string;
|
||||||
|
productDescription: string;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
import OrderConfirmationItem from '$lib/components/checkout/OrderConfirmationItem.svelte';
|
import OrderConfirmationItem from '$lib/components/checkout/OrderConfirmationItem.svelte';
|
||||||
import { formatCurrency } from '$lib/utils/currency';
|
import { formatCurrency } from '$lib/utils/currency';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
localBasket = value;
|
localBasket = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
let { checkoutSession, lineItems, paymentStatus } = data;
|
let { checkoutSession, lineItems } = data;
|
||||||
|
|
||||||
onDestroy(unsubscribe);
|
onDestroy(unsubscribe);
|
||||||
|
|
||||||
@@ -64,13 +64,15 @@
|
|||||||
>
|
>
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="w-full px-3 min-[400px]:px-6">
|
<div class="w-full px-3 min-[400px]:px-6">
|
||||||
{#if lineItems.length == 0}
|
{#if lineItems !== undefined && lineItems.length == 0}
|
||||||
<p class="font-semibold text-lg leading-7 text-black text-center">
|
<p class="font-semibold text-lg leading-7 text-black text-center">
|
||||||
No items in the basket
|
No items in the basket
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else if lineItems !== undefined}
|
||||||
{#each lineItems as item (item.id)}
|
{#each lineItems as item (item.id)}
|
||||||
<OrderConfirmationItem
|
<OrderConfirmationItem
|
||||||
|
imgSrc={item.imgSrc}
|
||||||
|
itemCategoryDescription={item.productDescription}
|
||||||
itemName={item.description}
|
itemName={item.description}
|
||||||
price={item.price.unit_amount / 100}
|
price={item.price.unit_amount / 100}
|
||||||
quantity={item.quantity}
|
quantity={item.quantity}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { PageLoad } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
import Stripe from 'stripe';
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({fetch}) => {
|
|
||||||
const response = await fetch('/api/checkout/status');
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Failed to fetch checkout status');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseJson = await response.json();
|
|
||||||
const checkoutSession: Stripe.Checkout.Session = responseJson.checkoutSession;
|
|
||||||
const lineItems: Stripe.LineItem[] = responseJson.lineItems.data;
|
|
||||||
const paymentStatus: Stripe.Checkout.Session.PaymentStatus = responseJson.paymentStatus;
|
|
||||||
|
|
||||||
if (paymentStatus == 'unpaid') {
|
|
||||||
throw redirect(303, '/checkout/cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
checkoutSession,
|
|
||||||
lineItems,
|
|
||||||
paymentStatus
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MagicLink from '$lib/components/MagicLink.svelte';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
let { supabase, session, url } = data;
|
||||||
|
$: ({ supabase, session, url } = data);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<MagicLink {supabase} />
|
||||||
@@ -1,18 +1,108 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ProductsListItem from '$lib/components/products/ProductsListItem.svelte';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
let products = [] as any;
|
import { debounce } from 'ts-debounce';
|
||||||
|
import ProductsListItem from '$lib/components/products/ProductsListItem.svelte';
|
||||||
|
|
||||||
|
let products = [] as any[];
|
||||||
let enablePagination = false;
|
let enablePagination = false;
|
||||||
|
let totalPages = 1;
|
||||||
|
let currentPage = 1;
|
||||||
|
const limit = 20;
|
||||||
|
let isLoading = false;
|
||||||
|
let errorMessage = '';
|
||||||
|
|
||||||
onMount(async () => {
|
// Debounced fetchProducts function to prevent rapid requests
|
||||||
const response = await fetch('/products');
|
const fetchProducts = debounce(async (page: number) => {
|
||||||
const responseJson = await response.json();
|
isLoading = true;
|
||||||
products = responseJson.data;
|
errorMessage = '';
|
||||||
if (responseJson.has_more) {
|
|
||||||
enablePagination = true;
|
// Ensure page number is within valid range
|
||||||
|
if (page < 1) {
|
||||||
|
page = 1;
|
||||||
|
} else if (page > totalPages) {
|
||||||
|
page = totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/products?limit=${limit}&offset=${offset}`);
|
||||||
|
const responseJson = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
products = responseJson.products;
|
||||||
|
const count = responseJson.count;
|
||||||
|
|
||||||
|
totalPages = Math.ceil(count / limit);
|
||||||
|
enablePagination = totalPages > 1;
|
||||||
|
currentPage = page;
|
||||||
|
} else {
|
||||||
|
throw new Error(responseJson.error || 'Failed to load products.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
errorMessage = error.message || 'Failed to load products.';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}, 300); // 300ms debounce delay
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
fetchProducts(currentPage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate pagination buttons with ellipsis for large numbers of pages
|
||||||
|
function getPageNumbers() {
|
||||||
|
const maxButtons = 5;
|
||||||
|
const pageNumbers = [];
|
||||||
|
|
||||||
|
if (totalPages <= maxButtons) {
|
||||||
|
// Show all pages as there aren't many
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pageNumbers.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let startPage = Math.max(currentPage - 2, 1);
|
||||||
|
let endPage = Math.min(currentPage + 2, totalPages);
|
||||||
|
|
||||||
|
if (currentPage <= 3) {
|
||||||
|
startPage = 1;
|
||||||
|
endPage = maxButtons;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
startPage = totalPages - (maxButtons - 1);
|
||||||
|
endPage = totalPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add first page and ellipsis if needed
|
||||||
|
if (startPage > 1) {
|
||||||
|
pageNumbers.push(1);
|
||||||
|
if (startPage > 2) {
|
||||||
|
pageNumbers.push('...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page numbers
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
pageNumbers.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis and last page if needed
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
if (endPage < totalPages - 1) {
|
||||||
|
pageNumbers.push('...');
|
||||||
|
}
|
||||||
|
pageNumbers.push(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageClick(pageNumber: number | string) {
|
||||||
|
if (typeof pageNumber === 'number' && pageNumber !== currentPage) {
|
||||||
|
fetchProducts(pageNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="py-24">
|
<section class="py-24">
|
||||||
@@ -22,20 +112,67 @@
|
|||||||
>
|
>
|
||||||
Available Products
|
Available Products
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{#each products as product (product.id)}
|
{#if isLoading}
|
||||||
<ProductsListItem {product} />
|
<p class="loading">Loading products...</p>
|
||||||
{/each}
|
{:else if errorMessage}
|
||||||
</div>
|
<p class="error">{errorMessage}</p>
|
||||||
{#if enablePagination}
|
{:else}
|
||||||
<div class="flex justify-center mt-12">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div class="join">
|
{#each products as product (product.id)}
|
||||||
<button class="join-item btn">1</button>
|
<ProductsListItem {product} />
|
||||||
<button class="join-item btn btn-active">2</button>
|
{/each}
|
||||||
<button class="join-item btn">3</button>
|
|
||||||
<button class="join-item btn">4</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if enablePagination}
|
||||||
|
<div class="flex justify-center mt-12">
|
||||||
|
<div class="join">
|
||||||
|
<!-- Previous Button -->
|
||||||
|
<button
|
||||||
|
class="join-item btn"
|
||||||
|
on:click={() => fetchProducts(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page Number Buttons -->
|
||||||
|
{#each getPageNumbers() as pageNumber}
|
||||||
|
{#if pageNumber === '...'}
|
||||||
|
<span class="join-item btn">...</span>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="join-item btn {currentPage === pageNumber ? 'btn-active' : ''}"
|
||||||
|
on:click={() => handlePageClick(pageNumber)}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Next Button -->
|
||||||
|
<button
|
||||||
|
class="join-item btn"
|
||||||
|
on:click={() => fetchProducts(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
|
||||||
import stripe from 'stripe';
|
import logger from '$lib/utils/logger/logger';
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
|
||||||
|
|
||||||
export const GET = async () => {
|
|
||||||
const products = await stripeClient.products.list({
|
|
||||||
limit: 100
|
|
||||||
});
|
|
||||||
|
|
||||||
|
export const GET = async ({ url }) => {
|
||||||
|
const limitParam: string | null = url.searchParams.get('limit');
|
||||||
|
const offsetParam: string | null = url.searchParams.get('offset');
|
||||||
try {
|
try {
|
||||||
// Get the price for each product
|
const limit = limitParam ? Number.parseInt(limitParam, 10) : 10;
|
||||||
for (const product of products.data) {
|
const offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0;
|
||||||
const price = await stripeClient.prices.retrieve(product.default_price as string);
|
|
||||||
if (price) {
|
const { products, count } = await getActiveProductsWithPrices(limit, offset);
|
||||||
product.actualPrice = price.unit_amount / 100;
|
|
||||||
}
|
return json({ products, count });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
return json(products);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { hasProductAccess } from '$lib/utils/supabase/admin';
|
||||||
|
import type { ProductInfo } from '$lib/types/Products/ProductInfo';
|
||||||
|
|
||||||
|
export const load = async ({ params, locals: { safeGetSession }, fetch }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
if (!session) {
|
||||||
|
throw redirect(303, '/login'); // Redirect unauthenticated users to login
|
||||||
|
}
|
||||||
|
|
||||||
|
const productResponse = await fetch(`/products/${params.productId}`);
|
||||||
|
const { product, isSubscription, price }: ProductInfo = await productResponse.json();
|
||||||
|
|
||||||
|
// Create the item object
|
||||||
|
const item = {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
categoryDescription: product.description,
|
||||||
|
imgAlt: product.description,
|
||||||
|
imgSrc: product.images[0],
|
||||||
|
priceId: product.default_price,
|
||||||
|
quantity: 1,
|
||||||
|
isSubscription: isSubscription,
|
||||||
|
price: (price.unit_amount ? price.unit_amount / 100 : NaN),
|
||||||
|
interval: price.recurring ? price.recurring.interval : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Check if the user has access to the 'EXAMPLE PRODUCT' product
|
||||||
|
const hasPurchasedProduct = await hasProductAccess(session.user.id, params.productId);
|
||||||
|
|
||||||
|
return { params, hasPurchasedProduct, item };
|
||||||
|
};
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { general } from '$lib/stores/generalStore.js';
|
import { basket, type Basket } from '$lib/stores/basket.js';
|
||||||
|
import { scheduleToast } from '$lib/stores/toastStore.js';
|
||||||
|
|
||||||
/** @type {import('./$types').PageData} */
|
/** @type {import('./$types').PageData} */
|
||||||
export let data;
|
export let data;
|
||||||
|
|
||||||
|
let { supabase, session, hasPurchasedProduct, item } = data;
|
||||||
|
$: ({ supabase, session, hasPurchasedProduct, item } = data);
|
||||||
|
|
||||||
let localBasket: Basket;
|
let localBasket: Basket;
|
||||||
const unsubscribeToBasket = basket.subscribe((value) => {
|
const unsubscribeToBasket = basket.subscribe((value) => {
|
||||||
localBasket = value;
|
localBasket = value;
|
||||||
@@ -16,6 +20,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function addToBasket() {
|
function addToBasket() {
|
||||||
|
if (!session) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
basket.update((value) => {
|
basket.update((value) => {
|
||||||
const existingItemIndex = value.items.findIndex((basketItem) => basketItem.id === item.id);
|
const existingItemIndex = value.items.findIndex((basketItem) => basketItem.id === item.id);
|
||||||
|
|
||||||
@@ -33,42 +40,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
general.update((value) => {
|
scheduleToast('Added to basket', 'success', 5000);
|
||||||
return { ...value, hideToast: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
general.update((value) => {
|
|
||||||
return { ...value, hideToast: true };
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let item = {};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
fetch(`/products/${data.productId}`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((product) => {
|
|
||||||
item = {
|
|
||||||
id: product.id,
|
|
||||||
name: product.name,
|
|
||||||
categoryDescription: product.description,
|
|
||||||
imgAlt: product.description,
|
|
||||||
imgSrc: product.images[0],
|
|
||||||
price: 0,
|
|
||||||
priceId: product.default_price,
|
|
||||||
quantity: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch the price of the product
|
|
||||||
fetch(`/products/prices/${product.default_price}`)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((price) => {
|
|
||||||
item.price = price.unit_amount / 100;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="relative">
|
<section class="relative">
|
||||||
@@ -83,9 +56,6 @@
|
|||||||
class="data w-full lg:pr-8 pr-0 xl:justify-start justify-center flex items-center max-lg:pb-10 xl:my-2 lg:my-5 my-0"
|
class="data w-full lg:pr-8 pr-0 xl:justify-start justify-center flex items-center max-lg:pb-10 xl:my-2 lg:my-5 my-0"
|
||||||
>
|
>
|
||||||
<div class="data w-full max-w-xl">
|
<div class="data w-full max-w-xl">
|
||||||
<!-- <p class="text-lg font-medium leading-8 text-indigo-600 mb-4">
|
|
||||||
Clothing / Menswear
|
|
||||||
</p> -->
|
|
||||||
<h2 class="font-manrope font-bold text-3xl leading-10 text-gray-900 mb-2 capitalize">
|
<h2 class="font-manrope font-bold text-3xl leading-10 text-gray-900 mb-2 capitalize">
|
||||||
{item.name}
|
{item.name}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -94,6 +64,7 @@
|
|||||||
class="font-manrope font-semibold text-2xl leading-9 text-gray-900 pr-5 sm:border-r border-gray-200 mr-5"
|
class="font-manrope font-semibold text-2xl leading-9 text-gray-900 pr-5 sm:border-r border-gray-200 mr-5"
|
||||||
>
|
>
|
||||||
£{item.price}
|
£{item.price}
|
||||||
|
{item.isSubscription ? `/ ${item.interval}` : ''}
|
||||||
</h6>
|
</h6>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
@@ -275,72 +246,83 @@
|
|||||||
<span class="font-normal text-base text-gray-900">all size is available</span>
|
<span class="font-normal text-base text-gray-900">all size is available</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<!-- <p class="text-gray-900 text-lg leading-8 font-medium mb-4">Size</p>
|
|
||||||
<div class="w-full pb-8 border-b border-gray-100 flex-wrap">
|
|
||||||
<div class="grid grid-cols-3 min-[400px]:grid-cols-5 gap-3 max-w-md">
|
|
||||||
<button
|
|
||||||
class="bg-white text-center py-1.5 px-6 w-full font-semibold text-lg leading-8 text-gray-900 border border-gray-200 flex items-center rounded-full justify-center transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-100 hover:border-gray-300 visited:border-gray-300 visited:bg-gray-50"
|
|
||||||
>S</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bg-white text-center py-1.5 px-6 w-full font-semibold text-lg leading-8 text-gray-900 border border-gray-200 flex items-center rounded-full justify-center transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-100 hover:border-gray-300 visited:border-gray-300 visited:bg-gray-50"
|
|
||||||
>M</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bg-white text-center py-1.5 px-6 w-full font-semibold text-lg leading-8 text-gray-900 border border-gray-200 flex items-center rounded-full justify-center transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-100 hover:border-gray-300 visited:border-gray-300 visited:bg-gray-50"
|
|
||||||
>L</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bg-white text-center py-1.5 px-6 w-full font-semibold text-lg leading-8 text-gray-900 border border-gray-200 flex items-center rounded-full justify-center transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-100 hover:border-gray-300 visited:border-gray-300 visited:bg-gray-50"
|
|
||||||
>XL</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="bg-white text-center py-1.5 px-6 w-full font-semibold text-lg leading-8 text-gray-900 border border-gray-200 flex items-center rounded-full justify-center transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-100 hover:border-gray-300 visited:border-gray-300 visited:bg-gray-50"
|
|
||||||
>XXL</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 py-8">
|
{#if !item.isSubscription && !hasPurchasedProduct}
|
||||||
<div class="flex sm:items-center sm:justify-center w-full">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 py-8">
|
||||||
<button
|
<div class="flex sm:items-center sm:justify-center w-full">
|
||||||
class="group py-4 px-6 border border-gray-400 rounded-l-full bg-white transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-300"
|
<button
|
||||||
>
|
class="group py-4 px-6 border border-gray-400 rounded-l-full bg-white transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-300"
|
||||||
<svg
|
|
||||||
class="stroke-gray-900 group-hover:stroke-black"
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 22 22"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
>
|
||||||
<path d="M16.5 11H5.5" stroke="" stroke-width="1.6" stroke-linecap="round" />
|
<svg
|
||||||
<path
|
class="stroke-gray-900 group-hover:stroke-black"
|
||||||
d="M16.5 11H5.5"
|
width="22"
|
||||||
stroke=""
|
height="22"
|
||||||
stroke-opacity="0.2"
|
viewBox="0 0 22 22"
|
||||||
stroke-width="1.6"
|
fill="none"
|
||||||
stroke-linecap="round"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
/>
|
>
|
||||||
<path
|
<path d="M16.5 11H5.5" stroke="" stroke-width="1.6" stroke-linecap="round" />
|
||||||
d="M16.5 11H5.5"
|
<path
|
||||||
stroke=""
|
d="M16.5 11H5.5"
|
||||||
stroke-opacity="0.2"
|
stroke=""
|
||||||
stroke-width="1.6"
|
stroke-opacity="0.2"
|
||||||
stroke-linecap="round"
|
stroke-width="1.6"
|
||||||
/>
|
stroke-linecap="round"
|
||||||
</svg>
|
/>
|
||||||
</button>
|
<path
|
||||||
<input
|
d="M16.5 11H5.5"
|
||||||
type="text"
|
stroke=""
|
||||||
class="font-semibold text-gray-900 cursor-pointer text-lg py-[13px] px-6 w-full sm:max-w-[118px] outline-0 border-y border-gray-400 bg-transparent placeholder:text-gray-900 text-center hover:bg-gray-50"
|
stroke-opacity="0.2"
|
||||||
placeholder="1"
|
stroke-width="1.6"
|
||||||
/>
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="font-semibold text-gray-900 cursor-pointer text-lg py-[13px] px-6 w-full sm:max-w-[118px] outline-0 border-y border-gray-400 bg-transparent placeholder:text-gray-900 text-center hover:bg-gray-50"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="group py-4 px-6 border border-gray-400 rounded-r-full bg-white transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-300"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="stroke-gray-900 group-hover:stroke-black"
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 5.5V16.5M16.5 11H5.5"
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11 5.5V16.5M16.5 11H5.5"
|
||||||
|
stroke="black"
|
||||||
|
stroke-opacity="0.2"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11 5.5V16.5M16.5 11H5.5"
|
||||||
|
stroke="black"
|
||||||
|
stroke-opacity="0.2"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="group py-4 px-6 border border-gray-400 rounded-r-full bg-white transition-all duration-300 hover:bg-gray-50 hover:shadow-sm hover:shadow-gray-300"
|
class="group py-4 px-5 rounded-full bg-indigo-50 text-indigo-600 font-semibold text-lg w-full flex items-center justify-center gap-2 transition-all duration-500 hover:bg-indigo-100"
|
||||||
|
on:click={addToBasket}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="stroke-gray-900 group-hover:stroke-black"
|
class="stroke-indigo-600"
|
||||||
width="22"
|
width="22"
|
||||||
height="22"
|
height="22"
|
||||||
viewBox="0 0 22 22"
|
viewBox="0 0 22 22"
|
||||||
@@ -348,77 +330,40 @@
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M11 5.5V16.5M16.5 11H5.5"
|
d="M10.7394 17.875C10.7394 18.6344 10.1062 19.25 9.32511 19.25C8.54402 19.25 7.91083 18.6344 7.91083 17.875M16.3965 17.875C16.3965 18.6344 15.7633 19.25 14.9823 19.25C14.2012 19.25 13.568 18.6344 13.568 17.875M4.1394 5.5L5.46568 12.5908C5.73339 14.0221 5.86724 14.7377 6.37649 15.1605C6.88573 15.5833 7.61377 15.5833 9.06984 15.5833H15.2379C16.6941 15.5833 17.4222 15.5833 17.9314 15.1605C18.4407 14.7376 18.5745 14.0219 18.8421 12.5906L19.3564 9.84059C19.7324 7.82973 19.9203 6.8243 19.3705 6.16215C18.8207 5.5 17.7979 5.5 15.7522 5.5H4.1394ZM4.1394 5.5L3.66797 2.75"
|
||||||
stroke="#9CA3AF"
|
stroke=""
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M11 5.5V16.5M16.5 11H5.5"
|
|
||||||
stroke="black"
|
|
||||||
stroke-opacity="0.2"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M11 5.5V16.5M16.5 11H5.5"
|
|
||||||
stroke="black"
|
|
||||||
stroke-opacity="0.2"
|
|
||||||
stroke-width="1.6"
|
stroke-width="1.6"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Add to basket
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{:else}
|
||||||
class="group py-4 px-5 rounded-full bg-indigo-50 text-indigo-600 font-semibold text-lg w-full flex items-center justify-center gap-2 transition-all duration-500 hover:bg-indigo-100"
|
{#if hasPurchasedProduct}
|
||||||
on:click={addToBasket}
|
<div class="flex items-center gap-3">
|
||||||
>
|
<button
|
||||||
<svg
|
class="group text-center w-full py-4 px-5 rounded-full bg-gray-100 text-gray-600 font-semibold text-lg w-full flex items-center justify-center gap-2 transition-all duration-500"
|
||||||
class="stroke-indigo-600"
|
disabled
|
||||||
width="22"
|
>
|
||||||
height="22"
|
You already own this
|
||||||
viewBox="0 0 22 22"
|
</button>
|
||||||
fill="none"
|
</div>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
{/if}
|
||||||
>
|
{#if item.isSubscription}
|
||||||
<path
|
<div class="flex items-center gap-3">
|
||||||
d="M10.7394 17.875C10.7394 18.6344 10.1062 19.25 9.32511 19.25C8.54402 19.25 7.91083 18.6344 7.91083 17.875M16.3965 17.875C16.3965 18.6344 15.7633 19.25 14.9823 19.25C14.2012 19.25 13.568 18.6344 13.568 17.875M4.1394 5.5L5.46568 12.5908C5.73339 14.0221 5.86724 14.7377 6.37649 15.1605C6.88573 15.5833 7.61377 15.5833 9.06984 15.5833H15.2379C16.6941 15.5833 17.4222 15.5833 17.9314 15.1605C18.4407 14.7376 18.5745 14.0219 18.8421 12.5906L19.3564 9.84059C19.7324 7.82973 19.9203 6.8243 19.3705 6.16215C18.8207 5.5 17.7979 5.5 15.7522 5.5H4.1394ZM4.1394 5.5L3.66797 2.75"
|
<form method="POST" action="/api/subscribe">
|
||||||
stroke=""
|
<input type="hidden" name="priceId" value={item.priceId} />
|
||||||
stroke-width="1.6"
|
<button
|
||||||
stroke-linecap="round"
|
type="submit"
|
||||||
/>
|
class="text-center w-full px-5 py-4 rounded-[100px] bg-indigo-600 flex items-center justify-center font-semibold text-lg text-white shadow-sm transition-all duration-500 hover:bg-indigo-700 hover:shadow-indigo-400"
|
||||||
</svg>
|
>
|
||||||
Add to basket</button
|
Subscribe
|
||||||
>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
<div class="flex items-center gap-3">
|
</div>
|
||||||
<button
|
{/if}
|
||||||
class="group transition-all duration-500 p-4 rounded-full bg-indigo-50 hover:bg-indigo-100 hover:shadow-sm hover:shadow-indigo-300"
|
{/if}
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="26"
|
|
||||||
height="26"
|
|
||||||
viewBox="0 0 26 26"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M4.47084 14.3196L13.0281 22.7501L21.9599 13.9506M13.0034 5.07888C15.4786 2.64037 19.5008 2.64037 21.976 5.07888C24.4511 7.5254 24.4511 11.4799 21.9841 13.9265M12.9956 5.07888C10.5204 2.64037 6.49824 2.64037 4.02307 5.07888C1.54789 7.51738 1.54789 11.4799 4.02307 13.9184M4.02307 13.9184L4.04407 13.939M4.02307 13.9184L4.46274 14.3115"
|
|
||||||
stroke="#4F46E5"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-miterlimit="10"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="text-center w-full px-5 py-4 rounded-[100px] bg-indigo-600 flex items-center justify-center font-semibold text-lg text-white shadow-sm transition-all duration-500 hover:bg-indigo-700 hover:shadow-indigo-400"
|
|
||||||
>
|
|
||||||
Buy Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
/** @type {import('./$types').PageLoad} */
|
|
||||||
export function load({ params }) {
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
import { type ProductInfo } from '$lib/types/Products/ProductInfo';
|
||||||
|
|
||||||
export const GET = async ({ params }) => {
|
export const GET = async ({ params }) => {
|
||||||
const { productId } = params;
|
const { productId } = params;
|
||||||
const product = await stripeClient.products.retrieve(productId);
|
const product = await stripeClient.products.retrieve(productId);
|
||||||
return json(product);
|
|
||||||
|
const price = await stripeClient.prices.retrieve(product.default_price as string);
|
||||||
|
|
||||||
|
const isSubscription = price.type === ('recurring' as Stripe.Price.Type);
|
||||||
|
|
||||||
|
const productInfo: ProductInfo = {
|
||||||
|
product,
|
||||||
|
price,
|
||||||
|
isSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(productInfo);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
import { stripe as stripeClient } from '$lib/utils/stripe';
|
||||||
import stripe from 'stripe';
|
|
||||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
|
||||||
|
|
||||||
export const GET = async ({ params }) => {
|
export const GET = async ({ params }) => {
|
||||||
const { priceId } = params;
|
const { priceId } = params;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect(303, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: user } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select(`name, id`)
|
||||||
|
.eq('id', session.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
return { session, user };
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
let { session, supabase, user } = data;
|
||||||
|
$: ({ session, supabase, user } = data);
|
||||||
|
|
||||||
|
const credits = writable<number>(0);
|
||||||
|
let errorMessage: string = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const pageUserId = user?.id;
|
||||||
|
if (pageUserId) {
|
||||||
|
const response = await fetch('/test/test-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'getUserCredits', userId: pageUserId })
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch credits');
|
||||||
|
const { credits: fetchedCredits } = await response.json();
|
||||||
|
credits.set(fetchedCredits);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCredits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/test/test-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'addCredits',
|
||||||
|
userId: user?.id,
|
||||||
|
credits: 10,
|
||||||
|
description: 'Test add credits'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to add credits');
|
||||||
|
const { credits: updatedCredits } = await response.json();
|
||||||
|
credits.set(updatedCredits);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deductCredits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/test/test-credits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'deductCredits',
|
||||||
|
userId: user?.id,
|
||||||
|
credits: 5,
|
||||||
|
description: 'Test deduct credits'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to deduct credits');
|
||||||
|
const { credits: updatedCredits } = await response.json();
|
||||||
|
credits.set(updatedCredits);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Credits Management for User: {user?.id}</h1>
|
||||||
|
<p>Credits Remaining: {$credits}</p>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p style="color: red;">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button on:click={addCredits}>Add 10 Credits</button>
|
||||||
|
<button on:click={deductCredits}>Deduct 5 Credits</button>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
margin: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { addCredits, deductCredits, getUserCredits } from '$lib/utils/supabase/admin';
|
||||||
|
|
||||||
|
export const POST = async ({ request }) => {
|
||||||
|
const { action, userId, credits, description } = await request.json();
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return json({ success: false, error: 'User ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'addCredits') {
|
||||||
|
await addCredits(userId, credits, description);
|
||||||
|
const updatedCredits = await getUserCredits(userId);
|
||||||
|
return json({ success: true, credits: updatedCredits });
|
||||||
|
} else if (action === 'deductCredits') {
|
||||||
|
await deductCredits(userId, credits, description);
|
||||||
|
const updatedCredits = await getUserCredits(userId);
|
||||||
|
return json({ success: true, credits: updatedCredits });
|
||||||
|
} else if (action === 'getUserCredits') {
|
||||||
|
const fetchedCredits = await getUserCredits(userId);
|
||||||
|
return json({ success: true, credits: fetchedCredits });
|
||||||
|
} else {
|
||||||
|
return json({ success: false, error: 'Invalid action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** @type {import('./$types').PageLoad} */
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { hasProductAccess } from '$lib/utils/supabase/admin';
|
||||||
|
import { VITE_PRODUCT_ID_EXAMPLEPRODUCT } from '$env/static/private'; // Import the example product ID from env
|
||||||
|
|
||||||
|
export const load = async ({ params, locals: { safeGetSession } }) => {
|
||||||
|
const { session } = await safeGetSession();
|
||||||
|
if (!session) {
|
||||||
|
throw redirect(303, '/login'); // Redirect unauthenticated users to login
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to the 'EXAMPLE PRODUCT' product
|
||||||
|
const accessGranted = await hasProductAccess(session.user.id, VITE_PRODUCT_ID_EXAMPLEPRODUCT);
|
||||||
|
if (!accessGranted) {
|
||||||
|
throw redirect(303, '/unauthorised');
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<h1>Example Product</h1>
|
||||||
|
<p>This is an example of a tool you could be selling.</p>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<h1>Unauthorised Access</h1>
|
||||||
|
<p>Sorry, you don't have permission to access this tool.</p>
|
||||||
+5
-10
@@ -49,8 +49,6 @@ enabled = true
|
|||||||
port = 54323
|
port = 54323
|
||||||
# External URL of the API server that frontend connects to.
|
# External URL of the API server that frontend connects to.
|
||||||
api_url = "http://127.0.0.1"
|
api_url = "http://127.0.0.1"
|
||||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
|
||||||
openai_api_key = "env(OPENAI_API_KEY)"
|
|
||||||
|
|
||||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||||
@@ -67,16 +65,13 @@ enabled = true
|
|||||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||||
file_size_limit = "50MiB"
|
file_size_limit = "50MiB"
|
||||||
|
|
||||||
[storage.image_transformation]
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
enabled = true
|
enabled = true
|
||||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||||
# in emails.
|
# in emails.
|
||||||
site_url = "http://127.0.0.1:3000"
|
site_url = "http://localhost:5173"
|
||||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
additional_redirect_urls = ["http://localhost:5173/auth/confirm", "http://localhost:5173/auth/callback"]
|
||||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||||
jwt_expiry = 3600
|
jwt_expiry = 3600
|
||||||
# If disabled, the refresh token will never expire.
|
# If disabled, the refresh token will never expire.
|
||||||
@@ -112,9 +107,9 @@ max_frequency = "1s"
|
|||||||
# sender_name = "Admin"
|
# sender_name = "Admin"
|
||||||
|
|
||||||
# Uncomment to customize email template
|
# Uncomment to customize email template
|
||||||
# [auth.email.template.invite]
|
[auth.email.template.magic_link]
|
||||||
# subject = "You have been invited"
|
subject = "Sign in to your account"
|
||||||
# content_path = "./supabase/templates/invite.html"
|
content_path = "./supabase/templates/magic_link.html"
|
||||||
|
|
||||||
[auth.sms]
|
[auth.sms]
|
||||||
# Allow/disallow new user signups via SMS to your project.
|
# Allow/disallow new user signups via SMS to your project.
|
||||||
|
|||||||
@@ -5,38 +5,26 @@
|
|||||||
create table users (
|
create table users (
|
||||||
-- UUID from auth.users
|
-- UUID from auth.users
|
||||||
id uuid references auth.users not null primary key,
|
id uuid references auth.users not null primary key,
|
||||||
full_name text,
|
name text
|
||||||
avatar_url text,
|
|
||||||
-- The customer's billing address, stored in JSON format.
|
|
||||||
billing_address jsonb,
|
|
||||||
-- Stores your customer's payment instruments.
|
|
||||||
payment_method jsonb
|
|
||||||
);
|
);
|
||||||
alter table users
|
alter table users enable row level security;
|
||||||
enable row level security;
|
create policy "Can view own user data." on users for select using (auth.uid() = id);
|
||||||
create policy "Can view own user data." on users
|
create policy "Can update own user data." on users for update using (auth.uid() = id);
|
||||||
for select using ((select auth.uid()) = id);
|
|
||||||
create policy "Can update own user data." on users
|
|
||||||
for update using ((select auth.uid()) = id);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
|
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
|
||||||
*/
|
*/
|
||||||
create function public.handle_new_user()
|
create function public.handle_new_user()
|
||||||
returns trigger as
|
returns trigger as $$
|
||||||
$$
|
begin
|
||||||
begin
|
insert into public.users (id, name)
|
||||||
insert into public.users (id, full_name, avatar_url)
|
values (new.id, new.raw_user_meta_data->>'name');
|
||||||
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
|
return new;
|
||||||
return new;
|
end;
|
||||||
end;
|
$$ language plpgsql security definer;
|
||||||
$$
|
|
||||||
language plpgsql security definer;
|
|
||||||
|
|
||||||
create trigger on_auth_user_created
|
create trigger on_auth_user_created
|
||||||
after insert on auth.users
|
after insert on auth.users
|
||||||
for each row
|
for each row execute procedure public.handle_new_user();
|
||||||
execute procedure public.handle_new_user();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CUSTOMERS
|
* CUSTOMERS
|
||||||
@@ -64,15 +52,17 @@ create table products (
|
|||||||
name text,
|
name text,
|
||||||
-- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.
|
-- The product's description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.
|
||||||
description text,
|
description text,
|
||||||
-- A URL of the product image in Stripe, meant to be displayable to the customer.
|
-- URLs of the product images in Stripe, meant to be displayable to the customer.
|
||||||
image text,
|
images text[],
|
||||||
|
-- A list of up to 15 features for this product. These are displayed in pricing tables.
|
||||||
|
features text[],
|
||||||
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
||||||
metadata jsonb
|
metadata jsonb,
|
||||||
|
"created_at" timestamp with time zone not null default now(),
|
||||||
|
"updated_at" timestamp with time zone not null default now()
|
||||||
);
|
);
|
||||||
alter table products
|
alter table products enable row level security;
|
||||||
enable row level security;
|
create policy "Allow public read-only access." on products for select using (true);
|
||||||
create policy "Allow public read-only access." on products
|
|
||||||
for select using (true);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PRICES
|
* PRICES
|
||||||
@@ -83,8 +73,8 @@ create type pricing_plan_interval as enum ('day', 'week', 'month', 'year');
|
|||||||
create table prices (
|
create table prices (
|
||||||
-- Price ID from Stripe, e.g. price_1234.
|
-- Price ID from Stripe, e.g. price_1234.
|
||||||
id text primary key,
|
id text primary key,
|
||||||
-- The ID of the prduct that this price belongs to.
|
-- The ID of the product that this price belongs to.
|
||||||
product_id text references products,
|
product_id text not null references products,
|
||||||
-- Whether the price can be used for new purchases.
|
-- Whether the price can be used for new purchases.
|
||||||
active boolean,
|
active boolean,
|
||||||
-- A brief description of the price.
|
-- A brief description of the price.
|
||||||
@@ -104,16 +94,14 @@ create table prices (
|
|||||||
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
||||||
metadata jsonb
|
metadata jsonb
|
||||||
);
|
);
|
||||||
alter table prices
|
alter table prices enable row level security;
|
||||||
enable row level security;
|
create policy "Allow public read-only access." on prices for select using (true);
|
||||||
create policy "Allow public read-only access." on prices
|
|
||||||
for select using (true);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SUBSCRIPTIONS
|
* SUBSCRIPTIONS
|
||||||
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
|
* Note: subscriptions are created and managed in Stripe and synced to our DB via Stripe webhooks.
|
||||||
*/
|
*/
|
||||||
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid');
|
create type subscription_status as enum ('trialing', 'active', 'canceled', 'incomplete', 'incomplete_expired', 'past_due', 'unpaid', 'paused');
|
||||||
create table subscriptions (
|
create table subscriptions (
|
||||||
-- Subscription ID from Stripe, e.g. sub_1234.
|
-- Subscription ID from Stripe, e.g. sub_1234.
|
||||||
id text primary key,
|
id text primary key,
|
||||||
@@ -122,6 +110,8 @@ create table subscriptions (
|
|||||||
status subscription_status,
|
status subscription_status,
|
||||||
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
-- Set of key-value pairs, used to store additional information about the object in a structured format.
|
||||||
metadata jsonb,
|
metadata jsonb,
|
||||||
|
-- ID of the product this subscription is for.
|
||||||
|
product_id text references products(id) not null,
|
||||||
-- ID of the price that created this subscription.
|
-- ID of the price that created this subscription.
|
||||||
price_id text references prices,
|
price_id text references prices,
|
||||||
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
|
-- Quantity multiplied by the unit amount of the price creates the amount of the subscription. Can be used to charge multiple seats.
|
||||||
@@ -145,15 +135,21 @@ create table subscriptions (
|
|||||||
-- If the subscription has a trial, the end of that trial.
|
-- If the subscription has a trial, the end of that trial.
|
||||||
trial_end timestamp with time zone default timezone('utc'::text, now())
|
trial_end timestamp with time zone default timezone('utc'::text, now())
|
||||||
);
|
);
|
||||||
alter table subscriptions
|
alter table subscriptions enable row level security;
|
||||||
enable row level security;
|
create policy "Can only view own subs data." on subscriptions for select using (auth.uid() = user_id);
|
||||||
create policy "Can only view own subs data." on subscriptions
|
|
||||||
for select using ((select auth.uid()) = user_id);
|
|
||||||
|
|
||||||
/**
|
-- Purchases table to record product purchases.
|
||||||
* REALTIME SUBSCRIPTIONS
|
create table purchases (
|
||||||
* Only allow realtime listening on public tables.
|
id uuid default uuid_generate_v4() primary key,
|
||||||
*/
|
user_id uuid references auth.users not null,
|
||||||
drop publication if exists supabase_realtime;
|
product_id text references products(id) not null,
|
||||||
create publication supabase_realtime
|
price_id text references prices(id) not null,
|
||||||
for table products, prices;
|
purchased_at timestamp with time zone not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enabling row-level security for purchases.
|
||||||
|
alter table purchases enable row level security;
|
||||||
|
|
||||||
|
-- Creating a policy to ensure that users can only view their own purchase data.
|
||||||
|
create policy "Can view own purchase data." on purchases for select using (auth.uid() = user_id);
|
||||||
|
create policy "Can insert own purchase data." on purchases for insert with check (auth.uid() = user_id);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
create table user_credits (
|
||||||
|
user_id uuid references auth.users not null primary key,
|
||||||
|
credits_remaining integer not null default 0,
|
||||||
|
last_updated timestamp with time zone not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table user_credits enable row level security;
|
||||||
|
|
||||||
|
create policy "Can view own credits." on user_credits
|
||||||
|
for select using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Uncomment and modify the update policy if allowing users to modify their own credits
|
||||||
|
-- create policy "Can update own credits." on user_credits
|
||||||
|
-- for update using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
create table credit_transactions (
|
||||||
|
id uuid default uuid_generate_v4() primary key,
|
||||||
|
user_id uuid references auth.users not null,
|
||||||
|
credits_change integer not null,
|
||||||
|
description text,
|
||||||
|
created_at timestamp with time zone not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table credit_transactions enable row level security;
|
||||||
|
|
||||||
|
create policy "Can view own transactions." on credit_transactions
|
||||||
|
for select using (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Adding indexing to improve query performance
|
||||||
|
create index on user_credits (user_id);
|
||||||
|
create index on credit_transactions (user_id);
|
||||||
|
|
||||||
|
-- Adding a check constraint to enforce data integrity
|
||||||
|
alter table credit_transactions
|
||||||
|
add constraint check_credits_change_nonzero check (credits_change <> 0);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Enable the "pg_cron" extension
|
||||||
|
create extension pg_cron with schema pg_catalog;
|
||||||
|
|
||||||
|
grant usage on schema cron to postgres;
|
||||||
|
grant all privileges on all tables in schema cron to postgres;
|
||||||
|
|
||||||
|
-- Create a function to delete expired tokens
|
||||||
|
CREATE OR REPLACE FUNCTION delete_expired_tokens()
|
||||||
|
RETURNS integer LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
deletion_count integer;
|
||||||
|
expiry_interval interval := '24 hours'::interval;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM account_deletion_requests
|
||||||
|
WHERE requested_at < NOW() - expiry_interval
|
||||||
|
RETURNING COUNT(*) INTO deletion_count;
|
||||||
|
RAISE NOTICE 'Deleted % expired token(s)', deletion_count;
|
||||||
|
RETURN deletion_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Schedule the function to run every hour
|
||||||
|
PERFORM cron.schedule(
|
||||||
|
'delete_expired_tokens',
|
||||||
|
'0 * * * *',
|
||||||
|
'SELECT delete_expired_tokens()'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RAISE NOTICE 'Failed to schedule token cleanup: %', SQLERRM;
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<h1>Sign in to your account</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Click this link to log in:
|
||||||
|
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"> Log In </a>
|
||||||
|
</p>
|
||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"module": "es2015"
|
"module": "es2015"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "node_modules/**/*.d.ts"]
|
"include": ["src/**/*", "node_modules/**/*.d.ts", ".svelte-kit/ambient.d.ts"]
|
||||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||||
//
|
//
|
||||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit(), purgeCss()]
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user