233 Commits

Author SHA1 Message Date
Olly 2294c6e276 Merge pull request #107 from jcreek/106-ensure-account-page-loads-when-there-are-no-transactions
fix(#106): Ensure account page loads when there are no transactions
2024-11-19 22:00:15 +00:00
Josh Creek d4f177f24b Merge pull request #108 from jcreek/docs-update
docs: update migration command
2024-11-19 22:00:04 +00:00
Josh Creek 8569048c3f fix(#106): Ensure account page loads when there are no transactions 2024-11-19 21:58:33 +00:00
OllyNicholass acb400231b docs: update migration command 2024-11-19 21:43:08 +00:00
Olly 3f037bf64e Merge pull request #105 from jcreek/98-improve-styling-of-credit-dashboard
98 improve styling of credit dashboard
2024-11-19 21:18:31 +00:00
OllyNicholass 5336d14c54 refactor: correct supabase client import 2024-11-19 21:14:54 +00:00
OllyNicholass cda0a1232a style(#98): add datatable styles 2024-11-19 21:12:40 +00:00
Olly 22940c53df Merge pull request #104 from jcreek/93-cookie-consent-compliance
feat(#93): add ability to reject cookies and in turn anayltic scripts
2024-11-19 21:02:56 +00:00
OllyNicholass acfdb75767 fix(#93): update logic to display consent when needed 2024-11-19 21:01:45 +00:00
Josh Creek 287fb78c0d Merge pull request #99 from jcreek/54-add-credit-usage-dashboard-for-users
54 add credit usage dashboard for users
2024-11-19 20:56:52 +00:00
Josh Creek 565d709904 Merge remote-tracking branch 'origin/master' into 54-add-credit-usage-dashboard-for-users 2024-11-19 20:51:04 +00:00
OllyNicholass 10b5747016 feat(#93): add ability to reject cookies and in turn anayltic scripts 2024-11-19 20:42:51 +00:00
Josh Creek 15d2f04754 Update src/routes/(user)/account/+page.svelte
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-19 20:39:44 +00:00
Josh Creek cf5771396c whoops 2024-11-19 20:33:52 +00:00
Josh Creek 13dd60428d refactor(#54): Move account into (user) and improve button styling 2024-11-19 20:32:31 +00:00
Olly b333ec2bef Merge pull request #97 from jcreek/74-add-token-expiry-mechanism-in-the-account_deletion_requests-table
feat(#74): Add token expiration for account deletion
2024-11-19 20:06:06 +00:00
Olly 319520e1ca Merge pull request #96 from jcreek/92-move-site-name-into-env-var
refactor(#92): Move site-name into env var
2024-11-19 20:05:29 +00:00
Olly 6268eacd6c Merge pull request #95 from jcreek/76-add-pagination-for-stripe-orders-on-account-page
76 add pagination for stripe orders on account page
2024-11-19 20:05:16 +00:00
Olly 4cbe02b8a0 Merge pull request #52 from jcreek/35-replace-navbar-with-a-better-one
35 replace navbar with a better one
2024-11-19 19:25:24 +00:00
OllyNicholass 9c01db944b feat(#35): schedule toast rather than display text 2024-11-19 19:23:17 +00:00
OllyNicholass 7f9958cb24 refactor(#35): remove readded pages 2024-11-19 18:49:26 +00:00
OllyNicholass 575b154ef4 Merge remote-tracking branch 'origin/master' into 35-replace-navbar-with-a-better-one 2024-11-18 23:34:09 +00:00
OllyNicholass 0189b20e0c refactor(#35): click anywhere handler 2024-11-18 23:20:18 +00:00
OllyNicholass ffde37ed15 fix(#35): password reset toast type 2024-11-18 23:16:49 +00:00
OllyNicholass 5a2b10b54b refactor(#35): announce toast notification 2024-11-18 23:15:09 +00:00
OllyNicholass 1113d509a8 refactor(#35): typesafe store subscription 2024-11-18 23:14:21 +00:00
OllyNicholass 8fcf650069 refactor(#35): accessible user button 2024-11-18 23:14:04 +00:00
OllyNicholass c25233f58e refactor(#35): hide other icons from SR 2024-11-18 23:09:10 +00:00
OllyNicholass 180325449c refactor(#35): hide basket icon from SR 2024-11-18 23:07:51 +00:00
OllyNicholass 1fbfb5c3d2 refactor(#35): accessible mobile nav button 2024-11-18 23:07:05 +00:00
OllyNicholass eae3ee99e8 refactor(#35): accessible basket nav link 2024-11-18 23:06:17 +00:00
OllyNicholass 87cd2b6c87 refactor(#35): whitespace 2024-11-18 23:03:47 +00:00
OllyNicholass 0ccf04fcbc refactor(#35): accessible navlinks 2024-11-18 23:03:17 +00:00
OllyNicholass 2174336512 refactor(#35): update type for supabase client in nav 2024-11-18 22:45:03 +00:00
OllyNicholass fb05f0b889 refactor(#35): split out toast and toast store 2024-11-18 22:41:41 +00:00
OllyNicholass 150c882827 refactor(#35): sign out to be accessible 2024-11-18 21:40:08 +00:00
OllyNicholass 0d8bc92dee refactor(#35): nav links to be accessible 2024-11-18 21:38:19 +00:00
Josh Creek 3b77f926e6 refactor(#54): Enhance error handling for database operations 2024-11-13 15:08:14 +00:00
Josh Creek 42663e7032 refactor(#54): Strongly type page load 2024-11-13 15:05:53 +00:00
Josh Creek 05a50a0ce0 refactor(#54): Improve month string formatting for consistent sorting 2024-11-13 15:05:04 +00:00
Josh Creek d62c92e527 feat(#54): Improve display of credit usage and accessibility 2024-11-13 14:51:13 +00:00
Josh Creek 93f41de185 feat(#54): Add credit usage dashboard 2024-11-13 12:00:59 +00:00
Josh Creek 77254c85fd refactor(#74): Enhance function robustness and configurability 2024-11-12 22:26:05 +00:00
Josh Creek 8788b90271 feat(#74): Add token expiration for account deletion 2024-11-12 21:53:01 +00:00
Josh Creek 61194d7132 refactor(#92): Move site-name into env var 2024-11-12 21:21:47 +00:00
Josh Creek b3c774b84c refactor(#76): Avoid using module-level variables for request-specific data 2024-11-12 20:59:48 +00:00
Josh Creek 5c5fc245b3 feat(#76): Improve accessibility 2024-11-12 20:25:26 +00:00
Josh Creek 981917cd6f chore(#76): Remove unused variables 2024-11-12 20:23:33 +00:00
Josh Creek 5de84d2ec1 feat(#76): Ensure pagination is only possible for valid pages 2024-11-12 20:09:01 +00:00
Josh Creek c77dc6ac47 feat(#76): Add pagination for stripe orders on account page 2024-11-11 20:04:02 +00:00
Josh Creek 62f7525238 Merge pull request #94 from jcreek/79-create-contact-page
feat(#79): Create contact page
2024-11-10 20:53:47 +00:00
Josh Creek c3125a0db1 feat(#79): Handle loading state 2024-11-10 19:58:50 +00:00
Josh Creek 3b1ffb21a6 feat(#79): Enhance form security and accessibility 2024-11-10 19:55:57 +00:00
Josh Creek 829d8cc031 feat(#79): Add rate limiting to contact page form 2024-11-10 19:45:40 +00:00
Josh Creek 6cdc0f0bde build(*): Fix broken closing tag preventing build 2024-11-10 19:40:59 +00:00
Josh Creek 4137f85190 fix(#79): Add check for result.data 2024-11-10 19:33:32 +00:00
Josh Creek 58842c6f1e feat(#79): Create contact page 2024-11-10 19:31:23 +00:00
Josh Creek b2bfd12a35 Merge pull request #71 from jcreek/60-if-you-sign-out-the-basket-doesnt-get-cleared
60 if you sign out the basket doesnt get cleared
2024-11-10 18:53:27 +00:00
Josh Creek 6a10ab5983 refactor(#60): Strongly type supabase client 2024-11-10 18:10:56 +00:00
Josh Creek 7ec5c5bb38 chore(#60): Remove logger from client side code 2024-11-06 21:09:29 +00:00
Josh Creek 4f4f122702 fix(#60): Fix logger import 2024-11-06 21:02:39 +00:00
Olly 19f9b077d6 Merge pull request #68 from jcreek/55-enable-account-closure
55 enable account closure
2024-11-06 20:51:30 +00:00
Josh Creek a8c702123e chore(#55): Fix whitespace 2024-11-06 18:03:49 +00:00
Josh Creek af4b7c4e4c chore(#55): Improve account deletion messaging 2024-11-06 17:55:50 +00:00
Josh Creek dc7f6ba18e chore(#60): Remove unused type 2024-11-06 17:50:31 +00:00
Josh Creek 0be329ff06 refactor(#60): Utilise errors when signing out 2024-11-06 17:49:52 +00:00
Josh Creek 39701b2155 fix(#60): Unsubscribe to basket store after logging out 2024-11-06 17:43:29 +00:00
Josh Creek ed6dc6db74 Merge branch 'master' into 60-if-you-sign-out-the-basket-doesnt-get-cleared 2024-11-05 22:41:56 +00:00
Josh Creek eec6e2041c chore(#55): Re-add pnpm lock 2024-11-05 22:40:25 +00:00
Josh Creek df8af5146f Merge branch 'master' into 55-enable-account-closure 2024-11-05 22:39:43 +00:00
Olly 7a91eb5173 Merge pull request #70 from jcreek/65-add-a-proper-error-logging-solution
65 add a proper error logging solution
2024-11-05 22:29:30 +00:00
OllyNicholass 57a800ef2c refactor(#65): add types to products page params 2024-11-05 22:13:20 +00:00
OllyNicholass 3cd3200d1e refactor(#65): add additional error handling & logs 2024-11-05 22:06:54 +00:00
OllyNicholass d329989230 refactor(#65): move scope of logged params
Type defs added where relevent
2024-11-05 21:44:48 +00:00
Josh Creek 026524a1b3 Merge branch 'master' into 65-add-a-proper-error-logging-solution 2024-11-05 21:07:55 +00:00
Olly a5a14a93ef Merge pull request #69 from jcreek/59-style-account-page
59 style account page
2024-11-05 20:57:49 +00:00
Olly f032005d8f Merge pull request #72 from jcreek/53-make-a-privacy-page
53 make a privacy page
2024-11-05 20:05:54 +00:00
Josh Creek 86ced6e7f9 refactor(#65): Enhance error logging with context 2024-11-04 19:12:27 +00:00
Josh Creek 8a251f775a refactor(#65): Enhance error logging with request context 2024-11-04 19:10:18 +00:00
Josh Creek ad30f04078 refactor(#59): Verify that 'transactions' is always defined before iteration 2024-11-04 19:00:23 +00:00
Josh Creek 506ceefe26 refactor(#59): Validate receipt links before showing the buttons 2024-11-04 18:56:56 +00:00
Josh Creek de565bb965 chore(#59): Remove debugging log 2024-11-04 18:51:57 +00:00
Josh Creek 3bbbd340c5 chore(#59): Improve clarity of error logs 2024-11-04 18:50:19 +00:00
Josh Creek d62b17ad65 docs(#55): Improve clarity around db migrations
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:38:13 +00:00
Josh Creek 259d83a71e refactor(#55): Improve error handling for requestAccountDeletion
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:32:52 +00:00
Josh Creek 26426f9804 feat(#55): Add error check for Brevo API key
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:28:37 +00:00
Josh Creek 1250261afe docs(#55): Add comments to .env.example
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2024-11-02 15:18:42 +00:00
Josh Creek f1f2fe99d7 feat(#59): Enable showing subscriptions and transactions 2024-11-01 20:12:08 +00:00
Josh Creek 3189d8932b feat(#59): Add styling to account page 2024-11-01 19:12:34 +00:00
Josh Creek fe1f49090c chore(#65): Comment out test code 2024-10-30 21:08:20 +00:00
Josh Creek 9253926c7d chore(*): Remove unused components 2024-10-30 21:06:28 +00:00
Josh Creek ddac66e3a9 feat(#65): Update example env 2024-10-30 21:06:20 +00:00
Josh Creek 3d89222eec feat(#65): Replace existing console logs 2024-10-30 21:06:12 +00:00
Josh Creek 2ef478e4b1 feat(#65): Add winston and axiom logging 2024-10-30 21:04:40 +00:00
Josh Creek 67b748b88e chore(*): Remove redundant sign out button 2024-10-30 18:48:27 +00:00
Josh Creek c23de036c7 feat(#60): Clear basket on sign out 2024-10-30 18:48:11 +00:00
Josh Creek 478ff600d3 feat(#53): Add policy link to cookie consent banner 2024-10-30 18:33:03 +00:00
Josh Creek cb1321825d feat(#53): Add policy links to footer 2024-10-30 18:32:45 +00:00
Josh Creek 464f365085 feat(#55): Enable sending account deletion emails through brevo npm package 2024-10-29 22:10:21 +00:00
OllyNicholass 7de7ccf9aa refactor: add issue ids to TODOs 2024-10-29 21:57:10 +00:00
OllyNicholass 8540a2c50f chore: add workspace vscode config for indentation 😉 2024-10-29 21:47:14 +00:00
OllyNicholass d49f4b2c38 refactor(#35): iNdEnTaTiOn 2024-10-29 21:46:52 +00:00
Olly b83b02fd12 Merge pull request #57 from jcreek/51-editing-the-input-for-basket-item-qty-doesnt-recalculate-subtotal
51 editing the input for basket item qty doesn't recalculate subtotal
2024-10-29 21:35:15 +00:00
OllyNicholass 550d9bac18 refactor(#35): rename dropdown handleClickAnywhere 2024-10-29 21:31:01 +00:00
OllyNicholass 21fe0cdab8 refactor(#35): fix indentation 2024-10-29 21:29:42 +00:00
OllyNicholass 00fb76ca4f refactor(#35): add keys to navlink loops 2024-10-29 21:29:09 +00:00
Josh Creek c4865337ee chore(*): Remove redundant example file 2024-10-29 21:20:34 +00:00
Josh Creek 33409bc375 feat(#55): Enable deleting account and anonymising purchases and subscriptions (no email sending) 2024-10-29 21:20:23 +00:00
Josh Creek 90875517da docs(#55): Add clearer migration commands 2024-10-29 21:17:10 +00:00
OllyNicholass 4be732005e feat(#35): make basket more prominent in nav 2024-10-29 20:44:45 +00:00
OllyNicholass 7c6afd169c chore: add stripe script to easily run local webhook 2024-10-29 20:43:18 +00:00
Josh Creek e4ef376064 feat(#53): Add cookie and privacy policies 2024-10-29 19:30:06 +00:00
Josh Creek 8bc8399b5f chore(*): Remove redundant page 2024-10-29 19:17:25 +00:00
Josh Creek f28f250fab fix(#51): Ensure the input for quantity is reactive 2024-10-29 19:17:05 +00:00
OllyNicholass 08a6f6d7cc refactor(#35): move nav components 2024-10-24 00:38:26 +01:00
Olly 243dc883ac Merge pull request #49 from jcreek/48-stripe-customer-creation-could-do-with-some-improved-security
refactor(#48): Improve security by moving create customer functionality to not be exposed in an endpoint
2024-10-24 00:29:44 +01:00
OllyNicholass 3a5e961d54 feat(#35): add new mobile menu 2024-10-24 00:28:28 +01:00
OllyNicholass 6ed1b25583 refactor(#35): conditionally display basket indicator 2024-10-22 22:19:19 +01:00
OllyNicholass 867f0f43f6 refactor(#35): center text in avatar button 2024-10-22 22:18:59 +01:00
OllyNicholass a16959d7ba refactor(#35): use dropdown component for user/signin 2024-10-22 22:04:19 +01:00
OllyNicholass d6baf11a7c refactor(#35): add guard clause to escape event listener if dropdown is not open 2024-10-22 21:53:44 +01:00
OllyNicholass f20e856d44 refactor(#35): Split dropdown into standalone component 2024-10-22 21:43:31 +01:00
OllyNicholass ec8adbf807 refactor(#35): Add dropdown and closing logic to parent nav link 2024-10-22 21:35:05 +01:00
OllyNicholass 5b7eafc294 refactor(#35): split nav into components control via JSON 2024-10-22 20:48:21 +01:00
OllyNicholass 6959bcdd04 refactor: remove unused refs 2024-10-22 20:47:01 +01:00
OllyNicholass 090224fc70 refactor(#35): move nav into component 2024-10-22 20:15:02 +01:00
Josh Creek 24a70f123a refactor(#48): Improve security by moving create customer functionality to not be exposed in an endpoint 2024-10-22 20:01:49 +01:00
Olly eb979add7d Merge pull request #47 from jcreek/11-add-the-description-and-fetching-the-images-from-stripe-on-checkout-success-page
feat(#11): Add the description and fetching the images from Stripe on checkout success page
2024-10-22 19:40:47 +01:00
Josh Creek 5744ad4db8 fix(*): Fix merge 2024-10-22 19:39:57 +01:00
Josh Creek cd65181e3e Merge branch 'master' into 11-add-the-description-and-fetching-the-images-from-stripe-on-checkout-success-page 2024-10-22 19:38:46 +01:00
Olly 532ac0cbe5 Merge pull request #46 from jcreek/45-checking-out-currently-creates-a-guest-customer-in-stripe
fix(#45): Enable customer creation when creating a stripe checkout session
2024-10-22 19:33:16 +01:00
Josh Creek e47fa54ea8 chore(*): Remove console log 2024-10-22 19:21:38 +01:00
Josh Creek a33779ce53 fix(#45): Re-add email 2024-10-22 19:16:09 +01:00
Josh Creek 570b11ef2a fix(#45): Remove customer creation from subscription checkout mode 2024-10-22 19:14:16 +01:00
Josh Creek 7825d29081 feat(#11): Add the description and fetching the images from Stripe on checkout success page 2024-10-22 18:40:32 +01:00
Josh Creek f3e6c236d5 fix(*): Prevent CORS errors with subscriptions 2024-10-20 20:15:56 +01:00
Josh Creek 4c44f772e5 feat(#31): Add support for subscriptions to create stripe customers 2024-10-20 19:50:07 +01:00
Josh Creek 7d994d1c09 feat(#31): Add ability to record the stripe customer id in the customers table when a purchase is made 2024-10-20 18:37:40 +01:00
Josh Creek 03157a6d2f fix(#45): Enable customer creation when creating a stripe checkout session 2024-10-20 17:21:43 +01:00
Josh Creek 7e458b7297 Merge pull request #44 from jcreek/catalog-improvements
Catalog improvements
2024-10-20 17:02:21 +01:00
OllyNicholass b61983368c feat: display interval for subscriptions 2024-10-15 23:47:34 +01:00
OllyNicholass 43bd4e8026 refactor: product page data fetching 2024-10-15 23:36:24 +01:00
OllyNicholass ec684f9074 feat(#31): populate stripe checkout session email 2024-10-15 23:05:19 +01:00
Josh Creek 0b2b0fc7f3 Merge pull request #43 from jcreek/40-cookie-banner-displays-briefly-on-slow-connections
fix(#40): Ensure cookie banner doesn't initially show for users who have already accepted cookies
2024-10-15 20:57:02 +01:00
Josh Creek 8eb309e393 Merge pull request #42 from jcreek/34-prevent-buying-a-product-you-have-already-purchased
feat(#34): display message in place of add to basket for prior purchase
2024-10-15 20:56:50 +01:00
OllyNicholass 29f418cc91 feat(#34): update already purchased button 2024-10-15 20:48:51 +01:00
OllyNicholass f34954f45a Merge branch '34-prevent-buying-a-product-you-have-already-purchased' of https://github.com/jcreek/SvelteKitSaasBoilerplate into 34-prevent-buying-a-product-you-have-already-purchased 2024-10-15 20:33:57 +01:00
OllyNicholass 616009ea05 feat(#34): display message in place of add to basket for prior purchase 2024-10-15 20:30:21 +01:00
Josh Creek 59258a4750 Merge pull request #41 from jcreek/6-enable-proper-pagination-for-the-products-page-ensuring-that-products-come-from-the-database-not-stripe
6 enable proper pagination for the products page ensuring that products come from the database not stripe
2024-10-15 20:13:14 +01:00
Josh Creek f46b6359ab refactor(*): Strongly type the return object 2024-10-15 20:06:51 +01:00
Josh Creek 79e2dcb744 refactor(*): Cast instead of declaring a new const 2024-10-15 19:54:11 +01:00
Josh Creek 15913ad413 fix(#40): Ensure cookie banner doesn't initially show for users who have already accepted cookies 2024-10-15 19:36:54 +01:00
OllyNicholass 8f7075db28 feat(#34): display message in place of add to basket for prior purchase 2024-10-15 19:36:17 +01:00
Josh Creek 8221224255 feat(#6): Add proper pagination support 2024-10-15 19:31:37 +01:00
Josh Creek 36a8620e70 feat(#6): Add a basic product page using the db rather than stripe 2024-10-15 19:20:40 +01:00
Josh Creek e666aa95d0 Merge pull request #38 from jcreek/37-enable-tracking-usage-of-credits
37 Enable tracking credits usage with proof of concept test page
2024-10-08 21:08:12 +01:00
Josh Creek 95a0c1dee5 Merge pull request #39 from jcreek/32-set-up-separate-flow-where-subscriptions-are-done-with-a-button-directly-from-the-product-page-and-cannot-be-added-to-the-basket
feat(#32): Add separate flow for subscriptions
2024-10-08 20:48:24 +01:00
Josh Creek 73cb21d9de fix(#32): Strongly type the recurring string enum 2024-10-08 20:34:25 +01:00
Josh Creek e51aeb7c63 feat(#32): Strongly type the price.type 2024-10-08 20:32:24 +01:00
Josh Creek 2f7ec9860a feat(#32): Add separate flow for subscriptions 2024-10-08 20:14:29 +01:00
Josh Creek 5d83c79b97 docs(#37): Add testing notes 2024-10-08 19:41:08 +01:00
Josh Creek db3425ec96 feat(#37): Enable tracking credits usage with proof of concept test page 2024-10-08 19:39:28 +01:00
Josh Creek fcbd69373c Merge pull request #36 from jcreek/29-refactor-stripe-and-supabase-implementation
29 refactor stripe and supabase implementation
2024-09-24 21:00:14 +01:00
OllyNicholass c9682be7c8 Merge branch '29-refactor-stripe-and-supabase-implementation' of https://github.com/jcreek/SvelteKitSaasBoilerplate into 29-refactor-stripe-and-supabase-implementation 2024-09-24 20:50:23 +01:00
OllyNicholass c674eb7c8d refactor(#29): remove unused url
Removing as new MagicLink component doesnt need the prop
2024-09-24 20:50:18 +01:00
Josh Creek 108b534a9c docs(#29): Add example product to docs 2024-09-24 20:48:28 +01:00
Josh Creek b2d4d005e1 feat(#29): Restyle navbar 2024-09-22 19:05:07 +01:00
Josh Creek d3a134891c feat(#29): Replace external magic link component 2024-09-22 13:11:07 +01:00
Josh Creek 5b137cf715 docs(#29): Update email docs 2024-09-22 12:26:37 +01:00
Josh Creek fce4c0e0a5 feat(#29): Add example product tool requiring purchase to access 2024-09-22 12:06:39 +01:00
Josh Creek a4764c547e feat(#29): Remove checkout button when not signed in 2024-09-21 20:51:00 +01:00
Josh Creek 05891ad510 feat(#29): Add ability to record that a user has purchased a product - part 2 2024-09-21 20:26:36 +01:00
Josh Creek d4f7f5041d feat(#29): Add ability to record that a user has purchased a product - part 1 2024-09-21 19:55:10 +01:00
Josh Creek b159c33487 docs(#29): Update local development docs 2024-09-21 18:54:00 +01:00
OllyNicholass 780d810597 Merge branch '29-refactor-stripe-and-supabase-implementation' of https://github.com/jcreek/SvelteKitSaasBoilerplate into 29-refactor-stripe-and-supabase-implementation 2024-09-17 21:46:26 +01:00
Josh Creek 47305ba570 docs(#29): Update supabase email docs 2024-09-17 21:43:55 +01:00
OllyNicholass a3873f93fb docs(#29): update env vars example 2024-09-17 21:41:12 +01:00
OllyNicholass 6e1d38ef58 feat(#29): use magic link to sign in 2024-09-17 21:14:39 +01:00
Josh Creek 0a906fa94d docs(#29): Add sending emails doc 2024-09-17 21:14:37 +01:00
OllyNicholass 32a3feef64 deps(#29): add vite & auth libs 2024-09-17 20:26:53 +01:00
OllyNicholass e1df81f104 refactor(#29): remove stripe sign up logic 2024-09-17 19:39:06 +01:00
OllyNicholass ec456a9b22 fix(#29): price record upsert 2024-09-17 19:38:33 +01:00
OllyNicholass 02a75e1393 Merge branch '29-refactor-stripe-and-supabase-implementation' of https://github.com/jcreek/SvelteKitSaasBoilerplate into 29-refactor-stripe-and-supabase-implementation 2024-09-17 19:30:47 +01:00
OllyNicholass 9089aa270b refactor(#29): centralise stripe client 2024-09-17 19:30:38 +01:00
OllyNicholass 7e05b3f361 refactor(#29): centralise stripe client 2024-09-17 19:29:43 +01:00
OllyNicholass 49a9b079ea chore(#29): update generate database types location 2024-09-17 19:28:03 +01:00
OllyNicholass 2b2e3e6063 Merge branch '29-refactor-stripe-and-supabase-implementation' of https://github.com/jcreek/SvelteKitSaasBoilerplate into 29-refactor-stripe-and-supabase-implementation 2024-09-17 19:13:58 +01:00
OllyNicholass 6cf1ffe4f4 chore(#29): generate database types 2024-09-17 18:37:32 +01:00
Josh Creek 7e0c06bab3 fix(#29): Ensure updating subscription works properly 2024-09-10 21:47:46 +01:00
Josh Creek b365d77c16 feat(#29): Handle upserting new fields for products and prices 2024-09-10 21:25:44 +01:00
Josh Creek 723f7eddf7 feat(#29): Add additional stripe webhook event handlers 2024-09-10 20:54:59 +01:00
Josh Creek 31a81f6312 feat(#29): Add conditional switch for sign in/up or welcome message 2024-09-10 20:45:43 +01:00
Josh Creek 65fdc1834a chore(*): Ensure env vars get strongly typed 2024-09-10 20:44:19 +01:00
Josh Creek cd1d4b7d71 chore(#29): Enable images with query params 2024-09-03 20:33:49 +01:00
Josh Creek 35105d31f1 chore(#29): Remove another redundant postcss file 2024-09-03 20:30:56 +01:00
Josh Creek f376a942b3 chore(#29): Remove redundant postcss file 2024-09-03 20:29:09 +01:00
Josh Creek 94de247d56 refactor(#29): Add strong typing to locals supabase 2024-09-03 20:27:59 +01:00
Josh Creek d95c8ec6cc docs(#29): Update database setup docs 2024-09-03 20:08:50 +01:00
Josh Creek 0423bfa701 feat(#29): Add magic link template 2024-09-03 20:07:15 +01:00
Josh Creek 5c0cce9e47 refactor(#29): Update supabase config.toml 2024-09-03 20:05:32 +01:00
Josh Creek ff71f7a0db chore(*): Add pnpm lock 2024-09-03 19:55:05 +01:00
Josh Creek a29df65cbb refactor(#29): Improve initial migration 2024-09-03 19:54:41 +01:00
Josh Creek ceba7622a9 Merge pull request #27 from jcreek/content-height
style(*): set min-height on main content
2024-07-23 19:14:18 +01:00
Josh Creek 3d296c8f2c Merge pull request #26 from jcreek/purge-css
Purge css
2024-07-23 19:13:46 +01:00
OllyNicholass 0990edab2e style(*): set min-height on main content 2024-07-16 21:29:03 +01:00
OllyNicholass bd560c41e1 build(*): purge css during build 2024-07-16 21:27:52 +01:00
OllyNicholass c76c6f4484 deps(*): add vite-plugin-tailwind-purgecss 2024-07-16 21:27:24 +01:00
Olly a964f19fc4 Merge pull request #25 from jcreek/feat/improve-build-time
chore(*): use postcss to build styles
2024-07-16 19:22:26 +01:00
OllyNicholass 06fc2c2cd2 chore(*): use postcss to build styles 2024-07-16 19:19:25 +01:00
Josh Creek b521314cc6 Merge pull request #24 from jcreek/14-add-ability-to-record-that-a-user-has-gained-access-to-a-software-product-after-purchase-1
14 add ability to record that a user has gained access to a software product after purchase
2024-07-05 16:19:32 +01:00
OllyNicholass 3fa3ace4ee fix(#14): remove empty pages 2024-07-05 16:07:06 +01:00
Josh Creek 67fcf8ea38 fix(#14): Replace throw with error log 2024-07-05 15:37:08 +01:00
OllyNicholass 37a8062c75 Merge branch '14-add-ability-to-record-that-a-user-has-gained-access-to-a-software-product-after-purchase-1' of https://github.com/jcreek/SvelteKitSaasBoilerplate into 14-add-ability-to-record-that-a-user-has-gained-access-to-a-software-product-after-purchase-1 2024-07-05 15:12:37 +01:00
OllyNicholass c32d62d420 docs(#14): add linked db command 2024-07-05 15:12:26 +01:00
OllyNicholass 1f0ff5bc5c chore(#14): update schema type def 2024-07-05 15:11:43 +01:00
Josh Creek 103ee16cec fix(#14): Fix invalid response 2024-07-05 15:07:33 +01:00
Josh Creek 337bc648ca fix(#14): Fix accessing private env var 2024-07-05 15:07:22 +01:00
Josh Creek 14992c3798 docs(*): Add to do item 2024-07-04 20:20:00 +01:00
Josh Creek 48aaee744f docs(#14): Add local db and migrations 2024-07-04 20:13:46 +01:00
Josh Creek 2fe426b3f4 chore(#14): Add initial db migration 2024-07-04 20:13:19 +01:00
Josh Creek a10d22d1f8 chore(#14): Strongly type supabase client 2024-07-04 20:05:22 +01:00
Josh Creek 8634e56bd1 docs(#14): Add links to database and stripe docs from readme 2024-07-04 19:54:23 +01:00
Josh Creek d30d6181cf docs(#14): Add stripe docs 2024-07-04 19:54:09 +01:00
Josh Creek 540352657c feat(#14): Add stripe webhooks 2024-07-04 19:35:23 +01:00
Josh Creek f10e0537bd feat(#14): Replace profile page with account page 2024-07-04 19:33:51 +01:00
Josh Creek 113268614b feat(#14): Update code to match documentation 2024-07-04 19:27:52 +01:00
Josh Creek e15565bb56 chore(#14): Add local supabase config 2024-07-04 19:26:00 +01:00
Josh Creek fd4befcd12 feat(#14): Add type definitions 2024-07-04 19:24:30 +01:00
Josh Creek ed4b5f3725 chore(#14): Update package versions 2024-07-04 19:24:18 +01:00
Josh Creek 83c8e44f33 feat(#14): Add db methods 2024-07-04 19:23:40 +01:00
Josh Creek 66698445f2 feat(#14): Update signup component to create a stripe customer on signup 2024-07-04 19:22:39 +01:00
Josh Creek 1303c97f72 docs(#14): Add type generation docs 2024-07-04 19:22:03 +01:00
Josh Creek 822b8c7235 docs(#14): Add db setup instructions 2024-07-04 19:18:26 +01:00
97 changed files with 11780 additions and 11554 deletions
+16 -1
View File
@@ -1,3 +1,18 @@
PUBLIC_SUPABASE_URL=""
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"
-1
View File
@@ -8,5 +8,4 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/static/output.css
.netlify
+4
View File
@@ -0,0 +1,4 @@
{
"editor.tabSize": 4,
"editor.formatOnSave": true
}
+14 -69
View File
@@ -17,77 +17,12 @@ 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.
### Local DB Development
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.
Ensure you have [Docker](https://docs.docker.com/get-docker/) installed on your development machine.
- [Local DB Url](http://localhost:54323/)
- [Local Email Monitoring Url](http://localhost:54324/)
You can start and stop the local DB environment with:
```bash
npx supabase start
npx supabase stop
```
#### Generate schema types (Typescript)
`npx supabase gen types typescript --local > ./src/lib/db_schema.ts`
The overarching `Database` type is used for a generic `SupabaseClient`. This is set globally in `src/app.d.ts`
```ts
import { SupabaseClient } from '@supabase/supabase-js'
import { Database } from '$lib/db_schema'
declare global {
namespace App {
interface Locals {
supabase: SupabaseClient<Database>
...
}
}
}
```
#### Migrations
There are two options to create a migration file:
1. [Writing the SQL manually](https://supabase.com/docs/guides/cli/local-development#database-migrations)
- Create new blank migration file
```bash
npx supabase migration new <migration_name>
```
- Add the SQL you want to deploy as part of the migration e.g.
```sql
create table
employees (
id bigint primary key generated always as identity,
name text,
email text,
created_at timestamptz default now()
);
```
- Reset the DB and apply the latest migrations
```bash
supabase db reset
```
2. [Generate SQL based on differences to the schema](https://supabase.com/docs/guides/cli/local-development#diffing-changes)
- With the local DB running, enter the studio and make your changes to the schema
- Generate the migration file containing the SQL with the differences in the schema
```bash
npx supabase db diff -f <migration_name>
```
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
@@ -113,4 +48,14 @@ In its current configuration, the application can be easily linked to Netlify vi
## To Do
- [Update types automatically](https://supabase.com/docs/guides/api/rest/generating-types#update-types-automatically-with-github-actions)
Please see the Issues tab for `enhancement` tagged issues.
## [Database setup](/docs/database-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.
+121
View File
@@ -0,0 +1,121 @@
# Database Setup
Databases are stored in Supabase. The auth table for signing up and logging in users is created automatically when you set up Supabase Auth.
## Initial Setup
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.
## Common commands for local development
- `npx supabase start` - Start the local Supabase instance
- `npx supabase stop` - Stop the local Supabase instance
- `npx supabase db reset` - Reset the local database
- `npx supabase gen types typescript --local > src/lib/types/supabase.d.ts` - Generate types for the local database
## Generating Types
To get the types for the tables, you can follow these instructions taken from [here](https://supabase.com/docs/guides/api/rest/generating-types#generating-types-using-supabase-cli):
`npm i supabase@">=1.8.1" --save-dev`
`npx supabase login`
`npx supabase init`
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 > src/lib/types/supabase.d.ts`
If you are using a local Supabase instance, you can use the following command to generate the types:
`npx supabase gen types typescript --local > src/lib/types/supabase.d.ts`
## Local DB Development
Ensure you have [Docker](https://docs.docker.com/get-docker/) installed and running.
You can start and stop the local DB environment with:
```bash
npx supabase start
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
There are two options to create a migration file:
1. [Writing the SQL manually](https://supabase.com/docs/guides/cli/local-development#database-migrations)
- Create new blank migration file
`npx supabase migration new <migration_name>`
- Add the SQL you want to deploy as part of the migration e.g.
```sql
create table
employees (
id bigint primary key generated always as identity,
name text,
email text,
created_at timestamptz default now()
);
```
- Reset the DB and apply the latest migrations
`npx supabase db reset`
2. [Generate SQL based on differences to the schema](https://supabase.com/docs/guides/cli/local-development#diffing-changes)
- With the local DB running, enter the studio and make your changes to the schema
- Generate the migration file containing the SQL with the differences in the schema
`npx supabase db diff -f <migration_name>`
## Applying migrations
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`
`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`
+39
View File
@@ -0,0 +1,39 @@
# Stripe webhook event listening
> First ensure you've installed the [Stripe CLI](https://docs.stripe.com/stripe-cli)
Log in using the stripe CLI:
```bash
stripe login
```
Obtain your Webhook signing secret:
```bash
stripe listen
```
Add the secret key to to your .env(.local):
`STRIPE_ENDPOINT_SECRET`
## Testing the webhook locally
You can use the [Stripe CLI to forward events to your local server](https://docs.stripe.com/webhooks):
```bash
stripe listen --forward-to localhost:5173/api/webhook/stripe
```
To disable HTTPS certificate verification, use the --skip-verify optional flag.
```bash
stripe listen --forward-to localhost:5173/api/webhook/stripe --skip-verify
```
## 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.
-10140
View File
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -3,14 +3,15 @@
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite dev",
"build": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite build",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"stripe": "stripe listen --forward-to localhost:5173/api/webhook/stripe",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css"
"generate": "supabase gen types typescript --local > src/lib/types/supabase.d.ts && prettier --write src/lib/types/supabase.d.ts"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
@@ -19,6 +20,8 @@
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.0.6",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"@types/cookie": "^0.6.0",
"@types/eslint": "^8.56.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
@@ -30,23 +33,33 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"postcss": "^8.4.38",
"postcss": "^8.4.39",
"postcss-load-config": "^5.1.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"supabase": "^1.167.4",
"supabase": "^1.178.2",
"svelte": "^4.2.8",
"svelte-check": "^3.6.2",
"tailwindcss": "^3.4.3",
"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",
"dependencies": {
"@supabase/ssr": "^0.1.0",
"@supabase/supabase-js": "^2.42.0",
"stripe": "^15.4.0"
"@axiomhq/winston": "^1.2.0",
"@getbrevo/brevo": "^2.2.0",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.44.2",
"chart.js": "^4.4.6",
"simple-datatables": "^9.2.1",
"stripe": "^15.4.0",
"ts-debounce": "^4.0.0",
"winston": "^3.15.0"
},
"engines": {
"node": ">=18.13.0"
}
},
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
}
+7458
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -2,22 +2,26 @@
// for information about these interfaces
// and what to do when importing types
import { SupabaseClient, Session, User } from '@supabase/supabase-js';
import { Database } from '$lib/db_schema';
import type { Database } from '$lib/types/supabase';
declare global {
const __DATE__: string;
const __RELOAD_SW__: boolean;
// Enable images with query params
declare module '*&img';
namespace App {
// interface Error {}
interface Locals {
supabase: SupabaseClient<Database>
safeGetSession(): Promise<{ session: Session | null; user: User | null }>
supabase: SupabaseClient<Database>;
safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
userid: string;
buildDate: string;
periodicUpdates: boolean;
}
interface PageData {
session: Session | null
}
session: Session | null;
}
// interface PageState {}
// interface Platform {}
}
-1
View File
@@ -5,7 +5,6 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<link rel="stylesheet" href="%sveltekit.assets%/output.css" />
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
+1
View File
@@ -1,3 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
+30 -22
View File
@@ -1,49 +1,57 @@
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';
import type { Handle } from '@sveltejs/kit';
import type { Database } from '$lib/types/supabase';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
cookies: {
get: (key) => event.cookies.get(key),
/**
* Note: You have to add the `path` variable to the
* set and remove method due to sveltekit's cookie API
* requiring this to be set, setting the path to an empty string
* will replicate previous/standard behaviour (https://kit.svelte.dev/docs/types#public-types-cookies)
*/
set: (key, value, options) => {
event.cookies.set(key, value, { ...options, path: '/' });
},
remove: (key, options) => {
event.cookies.delete(key, { ...options, path: '/' });
event.locals.supabase = createServerClient<Database>(
PUBLIC_SUPABASE_URL,
PUBLIC_SUPABASE_ANON_KEY,
{
cookies: {
getAll: () => event.cookies.getAll(),
/**
* SvelteKit's cookies API requires `path` to be explicitly set in
* the cookie options. Setting `path` to `/` replicates previous/
* standard behavior.
*/
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
event.cookies.set(name, value, { ...options, path: '/' });
});
}
}
}
});
);
/**
* Unlike `supabase.auth.getSession`, which is unsafe on the server because it
* doesn't validate the JWT, this function validates the JWT by first calling
* `getUser` and aborts early if the JWT signature is invalid.
* Unlike `supabase.auth.getSession()`, which returns the session _without_
* validating the JWT, this function also calls `getUser()` to validate the
* JWT before returning the session.
*/
event.locals.safeGetSession = async () => {
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
const {
data: { user },
error
} = await event.locals.supabase.auth.getUser();
if (error) {
// JWT validation has failed
return { session: null, user: null };
}
const {
data: { session }
} = await event.locals.supabase.auth.getSession();
return { session, user };
};
return resolve(event, {
filterSerializedResponseHeaders(name) {
return name === 'content-range';
return name === 'content-range' || name === 'x-supabase-api-version';
}
});
};
+24 -11
View File
@@ -1,27 +1,40 @@
<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;
let showBanner = !cookiesAccepted;
$: showBanner = !cookiesAccepted;
let showBanner = false;
const acceptCookies = () => {
if (isBrowser()) {
localStorage.setItem('cookiesAccepted', 'true');
showBanner = false;
onMount(() => {
if (!cookiesAccepted) {
const storedValue = localStorage.getItem('cookiesAccepted');
showBanner = storedValue === null;
}
};
});
const handleCookiesSubmit = (accepted: boolean) => {
localStorage.setItem('cookiesAccepted', accepted.toString());
showBanner = false;
};
</script>
{#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">
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>
<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
>
<button class="bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded" on:click={() => handleCookiesSubmit(false)}
>Reject</button
>
</div>
{/if}
+52
View File
@@ -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: '&#x25BC;',
open: '&#x25B2;'
};
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>
+52
View File
@@ -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>
-23
View File
@@ -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>
-126
View File
@@ -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>
+34 -6
View File
@@ -1,5 +1,8 @@
<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();
function emitSignedOutEvent() {
@@ -7,14 +10,39 @@
}
// 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() {
// TODO use the error from the response
const { error } = await supabase.auth.signOut().then(() => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error signing out:', error.message);
alert('Error signing out');
return;
}
try {
emitSignedOutEvent();
});
// Clear the basket
localBasket = { items: [], subtotal: 0 };
basket.set(localBasket);
unsubscribe();
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('basket');
}
} catch (err) {
console.error('Error clearing basket:', err);
alert('Error clearing basket');
}
}
</script>
<button on:click={signOut}>Sign Out</button>
<button on:click={signOut} aria-label="Sign out of your account" type="button">Sign Out</button>
-67
View File
@@ -1,67 +0,0 @@
<script lang="ts">
let email = '';
let password = '';
// Access the supabase client from the layout data
export let supabase: any;
async function signUpNewUser() {
try {
const { data, error } = await supabase.auth.signUp({
email: email,
password: password,
options: {
// Redirect URL after successful sign-up
redirectTo: '/welcome'
}
});
if (error) {
throw error;
}
// Handle success (optional)
} catch (error: any) {
console.error('Sign up error:', error.message);
// Handle error
}
}
</script>
<form class="card-body">
<div class="form-control">
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
><path
d="M2.5 3A1.5 1.5 0 0 0 1 4.5v.793c.026.009.051.02.076.032L7.674 8.51c.206.1.446.1.652 0l6.598-3.185A.755.755 0 0 1 15 5.293V4.5A1.5 1.5 0 0 0 13.5 3h-11Z"
/><path
d="M15 6.954 8.978 9.86a2.25 2.25 0 0 1-1.956 0L1 6.954V11.5A1.5 1.5 0 0 0 2.5 13h11a1.5 1.5 0 0 0 1.5-1.5V6.954Z"
/>
</svg>
<input type="text" class="grow" placeholder="Email" required bind:value={email} />
</label>
</div>
<div class="form-control">
<label class="input input-bordered flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4 opacity-70"
><path
fill-rule="evenodd"
d="M14 6a4 4 0 0 1-4.899 3.899l-1.955 1.955a.5.5 0 0 1-.353.146H5v1.5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5v-2.293a.5.5 0 0 1 .146-.353l3.955-3.955A4 4 0 1 1 14 6Zm-4-2a.75.75 0 0 0 0 1.5.5.5 0 0 1 .5.5.75.75 0 0 0 1.5 0 2 2 0 0 0-2-2Z"
clip-rule="evenodd"
/>
</svg>
<input type="password" class="grow" placeholder="Password" required bind:value={password} />
</label>
</div>
<div class="form-control mt-6">
<button class="btn btn-primary" on:click={signUpNewUser}>Sign Up</button>
</div>
</form>
@@ -11,10 +11,11 @@
export let price = 0;
export let quantity = 0;
$: dispatch('changeQuantity', { itemId, quantity });
function reduceQuantityByOne() {
if (quantity > 1) {
quantity--;
dispatch('changeQuantity', { itemId, quantity });
} else {
dispatch('removeItem', { itemId });
}
@@ -22,7 +23,6 @@
function increaseQuantityByOne() {
quantity++;
dispatch('changeQuantity', { itemId, quantity });
}
</script>
@@ -2,7 +2,7 @@
import { formatCurrency } from '$lib/utils/currency';
export let imgAlt = '';
export let imgSrc = '';
// export let itemCategoryDescription = '';
export let itemCategoryDescription = '';
export let itemName = '';
export let price = 0;
export let quantity = 0;
@@ -20,11 +20,11 @@
<h5 class="font-semibold text-xl leading-8 text-black max-[550px]:text-center">
{itemName}
</h5>
<!-- <p
<p
class="font-normal text-lg leading-8 text-gray-500 my-2 min-[550px]:my-3 max-[550px]:text-center"
>
{itemCategoryDescription}
</p> -->
</p>
<div class="flex items-center">
<h6
+124
View File
@@ -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">
export let product;
const defaultPrice = product.prices[0];
const isSubscription = defaultPrice.type === 'recurring';
</script>
<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="">
<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>
<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"
+65
View File
@@ -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}
-345
View File
@@ -1,345 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
operationName?: string
query?: string
variables?: Json
extensions?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
Tables: {
profiles: {
Row: {
created_at: string | null
full_name: string | null
id: string
updated_at: string | null
username: string | null
}
Insert: {
created_at?: string | null
full_name?: string | null
id: string
updated_at?: string | null
username?: string | null
}
Update: {
created_at?: string | null
full_name?: string | null
id?: string
updated_at?: string | null
username?: string | null
}
Relationships: [
{
foreignKeyName: "profiles_id_fkey"
columns: ["id"]
isOneToOne: true
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
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"]
},
]
}
}
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
}[]
}
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: {
[_ in never]: never
}
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never
-3
View File
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const general = writable({ hideToast: true, toastMessage: '', toastType: 'success' });
+23
View File
@@ -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));
}
+8
View File
@@ -0,0 +1,8 @@
export interface NavLinkItem {
text: string;
href?: string;
isParent?: boolean;
children?: NavLinkItem[];
isSignout?: boolean;
isUserAccount?: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
import Stripe from 'stripe';
export type ProductInfo = {
product: Stripe.Product;
price: Stripe.Price;
isSubscription: boolean;
};
+5
View File
@@ -0,0 +1,5 @@
export type Toast = {
hideToast: boolean,
toastMessage: string,
toastType: 'success' | 'error'
}
+485
View File
@@ -0,0 +1,485 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
graphql: {
Args: {
operationName?: string
query?: string
variables?: Json
extensions?: Json
}
Returns: Json
}
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
public: {
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: {
Row: {
id: string
stripe_customer_id: string | null
}
Insert: {
id: string
stripe_customer_id?: string | null
}
Update: {
id?: string
stripe_customer_id?: string | null
}
Relationships: [
{
foreignKeyName: "customers_id_fkey"
columns: ["id"]
isOneToOne: true
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
prices: {
Row: {
active: boolean | null
currency: string | null
description: string | null
id: string
interval: Database["public"]["Enums"]["pricing_plan_interval"] | null
interval_count: number | null
metadata: Json | null
product_id: string
trial_period_days: number | null
type: Database["public"]["Enums"]["pricing_type"] | null
unit_amount: number | null
}
Insert: {
active?: boolean | null
currency?: string | null
description?: string | null
id: string
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
interval_count?: number | null
metadata?: Json | null
product_id: string
trial_period_days?: number | null
type?: Database["public"]["Enums"]["pricing_type"] | null
unit_amount?: number | null
}
Update: {
active?: boolean | null
currency?: string | null
description?: string | null
id?: string
interval?: Database["public"]["Enums"]["pricing_plan_interval"] | null
interval_count?: number | null
metadata?: Json | null
product_id?: string
trial_period_days?: number | null
type?: Database["public"]["Enums"]["pricing_type"] | null
unit_amount?: number | null
}
Relationships: [
{
foreignKeyName: "prices_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "products"
referencedColumns: ["id"]
},
]
}
products: {
Row: {
active: boolean | null
created_at: string
description: string | null
features: string[] | null
id: string
images: string[] | null
metadata: Json | null
name: string | null
updated_at: string
}
Insert: {
active?: boolean | null
created_at?: string
description?: string | null
features?: string[] | null
id: string
images?: string[] | null
metadata?: Json | null
name?: string | null
updated_at?: string
}
Update: {
active?: boolean | null
created_at?: string
description?: string | null
features?: string[] | null
id?: string
images?: string[] | null
metadata?: Json | null
name?: string | null
updated_at?: string
}
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: {
Row: {
cancel_at: string | null
cancel_at_period_end: boolean | null
canceled_at: string | null
created: string
current_period_end: string
current_period_start: string
ended_at: string | null
id: string
metadata: Json | null
price_id: string | null
product_id: string
quantity: number | null
status: Database["public"]["Enums"]["subscription_status"] | null
trial_end: string | null
trial_start: string | null
user_id: string
}
Insert: {
cancel_at?: string | null
cancel_at_period_end?: boolean | null
canceled_at?: string | null
created?: string
current_period_end?: string
current_period_start?: string
ended_at?: string | null
id: string
metadata?: Json | null
price_id?: string | null
product_id: string
quantity?: number | null
status?: Database["public"]["Enums"]["subscription_status"] | null
trial_end?: string | null
trial_start?: string | null
user_id: string
}
Update: {
cancel_at?: string | null
cancel_at_period_end?: boolean | null
canceled_at?: string | null
created?: string
current_period_end?: string
current_period_start?: string
ended_at?: string | null
id?: string
metadata?: Json | null
price_id?: string | null
product_id?: string
quantity?: number | null
status?: Database["public"]["Enums"]["subscription_status"] | null
trial_end?: string | null
trial_start?: string | null
user_id?: string
}
Relationships: [
{
foreignKeyName: "subscriptions_price_id_fkey"
columns: ["price_id"]
isOneToOne: false
referencedRelation: "prices"
referencedColumns: ["id"]
},
{
foreignKeyName: "subscriptions_product_id_fkey"
columns: ["product_id"]
isOneToOne: false
referencedRelation: "products"
referencedColumns: ["id"]
},
{
foreignKeyName: "subscriptions_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
user_credits: {
Row: {
credits_remaining: number
last_updated: string
user_id: string
}
Insert: {
credits_remaining?: number
last_updated?: string
user_id: string
}
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: {
id?: string
name?: string | null
}
Relationships: [
{
foreignKeyName: "users_id_fkey"
columns: ["id"]
isOneToOne: true
referencedRelation: "users"
referencedColumns: ["id"]
},
]
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
pricing_plan_interval: "day" | "week" | "month" | "year"
pricing_type: "one_time" | "recurring"
subscription_status:
| "trialing"
| "active"
| "canceled"
| "incomplete"
| "incomplete_expired"
| "past_due"
| "unpaid"
| "paused"
}
CompositeTypes: {
[_ in never]: never
}
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never
+34
View File
@@ -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 };
+44
View File
@@ -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;
+30
View File
@@ -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;
}
+4
View File
@@ -0,0 +1,4 @@
import Stripe from 'stripe'
import { STRIPE_SECRET_KEY } from '$env/static/private'
export const stripe = new Stripe(STRIPE_SECRET_KEY)
+694
View File
@@ -0,0 +1,694 @@
import { createClient } from '@supabase/supabase-js';
import stripe from 'stripe';
import type { Database, Tables, TablesInsert } from '$lib/types/supabase';
import { PUBLIC_SUPABASE_URL } from '$env/static/public';
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 t = new Date(+0); // Unix epoch start.
t.setSeconds(secs);
return t;
};
type Product = Tables<'products'>;
type Price = Tables<'prices'>;
// Change to control trial period length
const TRIAL_PERIOD_DAYS = 0;
// Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context
// as it has admin privileges and overwrites RLS policies!
const supabaseAdmin = createClient<Database>(
PUBLIC_SUPABASE_URL || '',
SUPABASE_SERVICE_ROLE_KEY || ''
);
const upsertProductRecord = async (product: stripe.Product) => {
const productData: Product = {
id: product.id,
active: product.active,
name: product.name,
description: product.description ?? null,
features: product.marketing_features.map(({ name }) => name || '').filter((name) => !!name),
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]);
if (upsertError) throw new Error(`Product insert/update failed: ${upsertError.message}`);
logger.info(`Product inserted/updated: ${product.id}`);
};
const upsertPriceRecord = async (price: stripe.Price, retryCount = 0, maxRetries = 3) => {
const priceData: Price = {
id: price.id,
product_id: typeof price.product === 'string' ? price.product : '',
active: price.active,
currency: price.currency,
description: price.nickname ?? null,
metadata: price.metadata,
type: price.type,
unit_amount: price.unit_amount ?? null,
interval: price.recurring?.interval ?? null,
interval_count: price.recurring?.interval_count ?? null,
trial_period_days: price.recurring?.trial_period_days ?? TRIAL_PERIOD_DAYS
};
const { error: upsertError } = await supabaseAdmin.from('prices').upsert([priceData]);
if (upsertError?.message.includes('foreign key constraint')) {
if (retryCount < maxRetries) {
logger.info(`Retry attempt ${retryCount + 1} for price ID: ${price.id}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
await upsertPriceRecord(price, retryCount + 1, maxRetries);
} else {
throw new Error(
`Price insert/update failed after ${maxRetries} retries: ${upsertError.message}`
);
}
} else if (upsertError) {
throw new Error(`Price insert/update failed: ${upsertError.message}`);
} else {
logger.info(`Price inserted/updated: ${price.id}`);
}
};
const deleteProductRecord = async (product: stripe.Product) => {
const { error: deletionError } = await supabaseAdmin
.from('products')
.delete()
.eq('id', product.id);
if (deletionError) throw new Error(`Product deletion failed: ${deletionError.message}`);
logger.info(`Product deleted: ${product.id}`);
};
const deletePriceRecord = async (price: stripe.Price) => {
const { error: deletionError } = await supabaseAdmin.from('prices').delete().eq('id', price.id);
if (deletionError) throw new Error(`Price deletion failed: ${deletionError.message}`);
logger.info(`Price deleted: ${price.id}`);
};
const upsertCustomerToSupabase = async (uuid: string, customerId: string) => {
const { error: upsertError } = await supabaseAdmin
.from('customers')
.upsert([{ id: uuid, stripe_customer_id: customerId }]);
if (upsertError)
throw new Error(`Supabase customer record creation failed: ${upsertError.message}`);
return customerId;
};
const createCustomerInStripe = async (uuid: string, email: string) => {
const customerData = { metadata: { supabaseUUID: uuid }, email: email };
const newCustomer = await stripeClient.customers.create(customerData);
if (!newCustomer) throw new Error('Stripe customer creation failed.');
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 }) => {
// Check if the customer already exists in Supabase
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}`);
}
// Retrieve the Stripe customer ID using the Supabase customer ID, with email fallback
let stripeCustomerId: string | undefined;
if (existingSupabaseCustomer?.stripe_customer_id) {
const existingStripeCustomer = await stripeClient.customers.retrieve(
existingSupabaseCustomer.stripe_customer_id
);
stripeCustomerId = existingStripeCustomer.id;
} else {
// If Stripe ID is missing from Supabase, try to retrieve Stripe customer ID by email
const stripeCustomers = await stripeClient.customers.list({ email: email });
stripeCustomerId = stripeCustomers.data.length > 0 ? stripeCustomers.data[0].id : undefined;
}
// If still no stripeCustomerId, create a new customer in Stripe
const stripeIdToInsert = stripeCustomerId
? stripeCustomerId
: await createCustomerInStripe(uuid, email);
if (!stripeIdToInsert) throw new Error('Stripe customer creation failed.');
if (existingSupabaseCustomer && stripeCustomerId) {
// If Supabase has a record but doesn't match Stripe, update Supabase record
if (existingSupabaseCustomer.stripe_customer_id !== stripeCustomerId) {
const { error: updateError } = await supabaseAdmin
.from('customers')
.update({ stripe_customer_id: stripeCustomerId })
.eq('id', uuid);
if (updateError)
throw new Error(`Supabase customer record update failed: ${updateError.message}`);
console.warn(`Supabase customer record mismatched Stripe ID. Supabase record updated.`);
}
// If Supabase has a record and matches Stripe, return Stripe customer ID
return stripeCustomerId;
} else {
console.warn(`Supabase customer record was missing. A new record was created.`);
// If Supabase has no record, create a new record and return Stripe customer ID
const upsertedStripeCustomer = await upsertCustomerToSupabase(uuid, stripeIdToInsert);
if (!upsertedStripeCustomer) throw new Error('Supabase customer record creation failed.');
return upsertedStripeCustomer;
}
};
/**
* Copies the billing details from the payment method to the customer object.
*/
const copyBillingDetailsToCustomer = async (uuid: string, payment_method: stripe.PaymentMethod) => {
//Todo: check this assertion
const customer = payment_method.customer as string;
const { name, phone, address } = payment_method.billing_details;
if (!name || !phone || !address) return;
await stripeClient.customers.update(customer, { name, phone, address });
const { error: updateError } = await supabaseAdmin
.from('users')
.update({
billing_address: { ...address },
payment_method: { ...payment_method[payment_method.type] }
})
.eq('id', uuid);
if (updateError) throw new Error(`Customer update failed: ${updateError.message}`);
};
const manageSubscriptionStatusChange = async (
subscriptionId: string,
customerId: string,
createAction = false
) => {
// Get customer's UUID from mapping table.
const { data: customerData, error: noCustomerError } = await supabaseAdmin
.from('customers')
.select('id')
.eq('stripe_customer_id', customerId)
.single();
if (noCustomerError) throw new Error(`Customer lookup failed: ${noCustomerError.message}`);
const { id: uuid } = customerData!;
const subscription = await stripeClient.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method']
});
// Upsert the latest status of the subscription object.
const subscriptionData: TablesInsert<'subscriptions'> = {
id: subscription.id,
user_id: uuid,
metadata: subscription.metadata,
status: subscription.status,
product_id: subscription.items.data[0].price.product as string,
price_id: subscription.items.data[0].price.id,
quantity: 1, //subscription.quantity,
cancel_at_period_end: subscription.cancel_at_period_end,
cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null,
canceled_at: subscription.canceled_at
? toDateTime(subscription.canceled_at).toISOString()
: null,
current_period_start: toDateTime(subscription.current_period_start).toISOString(),
current_period_end: toDateTime(subscription.current_period_end).toISOString(),
created: toDateTime(subscription.created).toISOString(),
ended_at: subscription.ended_at ? toDateTime(subscription.ended_at).toISOString() : null,
trial_start: subscription.trial_start
? toDateTime(subscription.trial_start).toISOString()
: null,
trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null
};
try {
const { error: upsertError } = await supabaseAdmin
.from('subscriptions')
.upsert([subscriptionData]);
if (upsertError) {
throw new Error(`Subscription insert/update failed: ${upsertError.message}`);
}
} catch (error) {
logger.error(error);
}
logger.info(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`);
// // For a new subscription copy the billing details to the customer object.
// // NOTE: This is a costly operation and should happen at the very end.
// if (createAction && subscription.default_payment_method && uuid) {
// 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 {
upsertProductRecord,
upsertPriceRecord,
deleteProductRecord,
deletePriceRecord,
createOrRetrieveCustomer,
manageSubscriptionStatusChange,
recordProductPurchase,
hasProductAccess,
addCredits,
deductCredits,
getUserCredits,
getActiveProductsWithPrices,
getProductById,
upsertCustomerToSupabase,
getStripeCustomerId,
getUserSubscriptions,
getUserTransactions,
requestAccountDeletion,
deleteAccount
};
+6
View File
@@ -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>
+125
View File
@@ -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>
+134
View File
@@ -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' });
}
}
};
+267
View File
@@ -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>
+53
View File
@@ -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
};
}
};
+164
View File
@@ -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>
+3 -2
View File
@@ -1,10 +1,11 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
const { session, user } = await safeGetSession();
return {
session,
user
user,
cookies: cookies.getAll()
};
};
+18 -162
View File
@@ -1,39 +1,21 @@
<script lang="ts">
import 'tailwindcss/tailwind.css';
import { onDestroy, onMount } from 'svelte';
import '../app.postcss';
import { onMount } from 'svelte';
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 SignIn from '$lib/components/SignIn.svelte';
import SignOut from '$lib/components/SignOut.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;
let { 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;
onMount(() => {
const { data } = supabase.auth.onAuthStateChange((event, _session) => {
if (_session?.expires_at !== session?.expires_at) {
const { data } = supabase.auth.onAuthStateChange((event, newSession) => {
if (newSession?.expires_at !== session?.expires_at) {
// tells SvelteKit that the root +layout.ts load function should be executed whenever the session updates to keep the page store in sync.
invalidate('supabase:auth');
}
@@ -63,96 +45,7 @@
</svelte:head>
<header>
<nav class="navbar bg-primary text-primary-content">
<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="/profile"> Profile </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>
<Nav {session} {supabase} />
</header>
<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"
></path>
</svg>
<p>Copyright © 2024 - All right reserved</p>
<p>Copyright © 2024 - All rights reserved</p>
</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">
<a href="/">
<svg
@@ -218,54 +115,9 @@
</nav>
</footer>
<div
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}
<Toast />
<span>{localGeneral.toastMessage == '' ? 'Added to basket' : localGeneral.toastMessage}</span>
</div>
<style scoped>
<style scoped lang="postcss">
.user-circle {
width: 40px;
height: 40px;
@@ -277,4 +129,8 @@
align-items: center;
font-size: 20px; /* Adjust font size as needed */
}
main {
min-height: calc(100dvh - 68px - 68px);
}
</style>
+16 -15
View File
@@ -1,25 +1,26 @@
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr';
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import type { LayoutLoad } from './$types';
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
depends('supabase:auth');
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
},
cookies: {
get(key) {
if (!isBrowser()) {
return JSON.stringify(data.session);
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
}
const cookie = parse(document.cookie);
return cookie[key];
}
}
});
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
},
cookies: {
getAll() {
return data.cookies;
}
}
});
/**
* It's fine to use `getSession` here, because on the client, `getSession` is
+17 -11
View File
@@ -1,23 +1,29 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { type User } from '@supabase/auth-js';
import SignUp from '$lib/components/SignUp.svelte';
import MagicLink from '$lib/components/MagicLink.svelte';
export let data;
let { supabase, session } = data;
$: ({ supabase, session } = data);
</script>
<div class="hero bg-base-100 my-36">
<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">
<SignUp {supabase} />
{#if session !== null}
<div class="hero bg-base-100 my-36">
Welcome back {session.user.email}
</div>
{:else}
<div class="hero bg-base-100 my-36">
<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>
{/if}
<div
class="md:container md:mx-auto justify-self-center flex justify-center items-center h-full flex-col mb-10"
+43 -10
View File
@@ -1,11 +1,21 @@
import { type RequestHandler, redirect } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
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 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 items: { priceId: string; quantity: number }[] = [];
@@ -19,14 +29,37 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
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,
mode: 'payment',
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
cookies.set('checkout_session_id', session.id, { path: '/' });
return redirect(303, session.url as string);
cookies.set('checkout_session_id', checkoutSession.id, { path: '/' });
return redirect(303, checkoutSession.url as string);
};
+1 -3
View File
@@ -1,8 +1,6 @@
import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
import stripe from 'stripe';
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 }) => {
const sessionId = cookies.get('checkout_session_id');
+18
View File
@@ -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
}
+65
View File
@@ -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);
};
+116
View File
@@ -0,0 +1,116 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import stripe from 'stripe';
import { STRIPE_ENDPOINT_SECRET } from '$env/static/private';
import {
deletePriceRecord,
deleteProductRecord,
manageSubscriptionStatusChange,
upsertPriceRecord,
upsertProductRecord,
recordProductPurchase
} from '$lib/utils/supabase/admin';
import { stripe as stripeClient } from '$lib/utils/stripe';
import logger from '$lib/utils/logger/logger';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.text();
try {
let event = JSON.parse(body) as stripe.Event;
// Get the signature sent by Stripe
const signature = request.headers.get('stripe-signature');
// Only verify the event if you have an endpoint secret defined.
// Otherwise use the basic event deserialized with JSON.parse
if (STRIPE_ENDPOINT_SECRET && signature) {
try {
event = stripeClient.webhooks.constructEvent(body, signature, STRIPE_ENDPOINT_SECRET);
} catch (err) {
logger.error(`⚠️ Webhook signature verification failed.`, err.message);
return {
status: 400,
body: {}
};
}
}
switch (event.type) {
case 'product.created':
case 'product.updated':
await upsertProductRecord(event.data.object as stripe.Product);
break;
case 'product.deleted':
await deleteProductRecord(event.data.object as stripe.Product);
break;
case 'price.created':
case 'price.updated':
await upsertPriceRecord(event.data.object as stripe.Price);
break;
case 'price.deleted':
await deletePriceRecord(event.data.object as stripe.Price);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
case 'customer.subscription.paused':
case 'customer.subscription.resumed': {
const subscription = event.data.object as stripe.Subscription;
await manageSubscriptionStatusChange(
subscription.id,
subscription.customer as string,
event.type === 'customer.subscription.created'
);
break;
}
case 'checkout.session.completed': {
const checkoutSession = event.data.object as stripe.Checkout.Session;
if (checkoutSession.mode === 'subscription') {
const subscriptionId = checkoutSession.subscription;
await manageSubscriptionStatusChange(
subscriptionId as string,
checkoutSession.customer as string,
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;
}
default:
logger.info(`Unhandled event type ${event.type}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'An unknown error has occurred';
return new Response(`Webhook Error: ${message}`, { status: 500 });
}
return json({ received: true });
};
+14
View File
@@ -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
View File
@@ -0,0 +1 @@
<p>Login error</p>
@@ -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>
+11
View File
@@ -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;
};
+31 -23
View File
@@ -1,8 +1,12 @@
<script src="https://js.stripe.com/v3/" lang="ts">
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';
export let data;
let { supabase, session } = data;
$: ({ supabase, session } = data);
let localBasket: Basket;
const unsubscribe = basket.subscribe((value) => {
localBasket = value;
@@ -101,28 +105,32 @@
/>
</svg>
</a>
<button
type="submit"
class="btn btn-primary"
disabled={localBasket.items && localBasket.items.length === 0}
>Continue to Payment
<svg
class="ml-2"
xmlns="http://www.w3.org/2000/svg"
width="23"
height="22"
viewBox="0 0 23 22"
fill="none"
>
<path
d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998"
stroke="white"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if session?.user !== undefined}
<button
type="submit"
class="btn btn-primary"
disabled={localBasket.items && localBasket.items.length === 0}
>Continue to Payment
<svg
class="ml-2"
xmlns="http://www.w3.org/2000/svg"
width="23"
height="22"
viewBox="0 0 23 22"
fill="none"
>
<path
d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998"
stroke="white"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{:else}
<p>You must sign in to checkout</p>
{/if}
</div>
</form>
</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;
};
+6 -4
View File
@@ -1,6 +1,6 @@
<script lang="ts">
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 { formatCurrency } from '$lib/utils/currency';
import type { PageData } from './$types';
@@ -11,7 +11,7 @@
localBasket = value;
});
let { checkoutSession, lineItems, paymentStatus } = data;
let { checkoutSession, lineItems } = data;
onDestroy(unsubscribe);
@@ -64,13 +64,15 @@
>
</div> -->
<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">
No items in the basket
</p>
{:else}
{:else if lineItems !== undefined}
{#each lineItems as item (item.id)}
<OrderConfirmationItem
imgSrc={item.imgSrc}
itemCategoryDescription={item.productDescription}
itemName={item.description}
price={item.price.unit_amount / 100}
quantity={item.quantity}
-27
View File
@@ -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
};
};
+38
View File
@@ -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 });
}
}
};
+103
View File
@@ -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}
+9
View File
@@ -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} />
+158 -21
View File
@@ -1,18 +1,108 @@
<script lang="ts">
import ProductsListItem from '$lib/components/products/ProductsListItem.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 totalPages = 1;
let currentPage = 1;
const limit = 20;
let isLoading = false;
let errorMessage = '';
onMount(async () => {
const response = await fetch('/products');
const responseJson = await response.json();
products = responseJson.data;
if (responseJson.has_more) {
enablePagination = true;
// Debounced fetchProducts function to prevent rapid requests
const fetchProducts = debounce(async (page: number) => {
isLoading = true;
errorMessage = '';
// 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>
<section class="py-24">
@@ -22,20 +112,67 @@
>
Available Products
</h2>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{#each products as product (product.id)}
<ProductsListItem {product} />
{/each}
</div>
{#if enablePagination}
<div class="flex justify-center mt-12">
<div class="join">
<button class="join-item btn">1</button>
<button class="join-item btn btn-active">2</button>
<button class="join-item btn">3</button>
<button class="join-item btn">4</button>
</div>
{#if isLoading}
<p class="loading">Loading products...</p>
{:else if errorMessage}
<p class="error">{errorMessage}</p>
{:else}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{#each products as product (product.id)}
<ProductsListItem {product} />
{/each}
</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}
</div>
</section>
<style>
.loading {
text-align: center;
margin: 20px 0;
}
.error {
color: red;
text-align: center;
margin: 20px 0;
}
</style>
+31 -17
View File
@@ -1,23 +1,37 @@
import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
export const GET = async () => {
const products = await stripeClient.products.list({
limit: 100
});
import { getActiveProductsWithPrices } from '$lib/utils/supabase/admin';
import logger from '$lib/utils/logger/logger';
export const GET = async ({ url }) => {
const limitParam: string | null = url.searchParams.get('limit');
const offsetParam: string | null = url.searchParams.get('offset');
try {
// Get the price for each product
for (const product of products.data) {
const price = await stripeClient.prices.retrieve(product.default_price as string);
if (price) {
product.actualPrice = price.unit_amount / 100;
}
const limit = limitParam ? Number.parseInt(limitParam, 10) : 10;
const offset = offsetParam ? Number.parseInt(offsetParam, 10) : 0;
const { products, count } = await getActiveProductsWithPrices(limit, offset);
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 };
};
+112 -167
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
import { general } from '$lib/stores/generalStore.js';
import { onDestroy } from 'svelte';
import { redirect } from '@sveltejs/kit';
import { basket, type Basket } from '$lib/stores/basket.js';
import { scheduleToast } from '$lib/stores/toastStore.js';
/** @type {import('./$types').PageData} */
export let data;
let { supabase, session, hasPurchasedProduct, item } = data;
$: ({ supabase, session, hasPurchasedProduct, item } = data);
let localBasket: Basket;
const unsubscribeToBasket = basket.subscribe((value) => {
localBasket = value;
@@ -16,6 +20,9 @@
});
function addToBasket() {
if (!session) {
redirect(303, '/');
}
basket.update((value) => {
const existingItemIndex = value.items.findIndex((basketItem) => basketItem.id === item.id);
@@ -33,42 +40,8 @@
}
});
general.update((value) => {
return { ...value, hideToast: false };
});
setTimeout(() => {
general.update((value) => {
return { ...value, hideToast: true };
});
}, 5000);
scheduleToast('Added to basket', 'success', 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>
<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"
>
<div class="data w-full max-w-xl">
<!-- <p class="text-lg font-medium leading-8 text-indigo-600 mb-4">
Clothing&nbsp; /&nbsp; Menswear
</p> -->
<h2 class="font-manrope font-bold text-3xl leading-10 text-gray-900 mb-2 capitalize">
{item.name}
</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"
>
£{item.price}
{item.isSubscription ? `/ ${item.interval}` : ''}
</h6>
<div class="flex items-center gap-2">
<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>
</li>
</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">
<div class="flex sm:items-center sm:justify-center w-full">
<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"
{#if !item.isSubscription && !hasPurchasedProduct}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 py-8">
<div class="flex sm:items-center sm:justify-center w-full">
<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"
>
<path d="M16.5 11H5.5" stroke="" stroke-width="1.6" stroke-linecap="round" />
<path
d="M16.5 11H5.5"
stroke=""
stroke-opacity="0.2"
stroke-width="1.6"
stroke-linecap="round"
/>
<path
d="M16.5 11H5.5"
stroke=""
stroke-opacity="0.2"
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"
/>
<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" />
<path
d="M16.5 11H5.5"
stroke=""
stroke-opacity="0.2"
stroke-width="1.6"
stroke-linecap="round"
/>
<path
d="M16.5 11H5.5"
stroke=""
stroke-opacity="0.2"
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
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
class="stroke-gray-900 group-hover:stroke-black"
class="stroke-indigo-600"
width="22"
height="22"
viewBox="0 0 22 22"
@@ -348,77 +330,40 @@
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"
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=""
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
Add to basket
</button>
</div>
<button
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
class="stroke-indigo-600"
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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=""
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
Add to basket</button
>
</div>
<div class="flex items-center gap-3">
<button
class="group transition-all duration-500 p-4 rounded-full bg-indigo-50 hover:bg-indigo-100 hover:shadow-sm hover:shadow-indigo-300"
>
<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>
{:else}
{#if hasPurchasedProduct}
<div class="flex items-center gap-3">
<button
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"
disabled
>
You already own this
</button>
</div>
{/if}
{#if item.isSubscription}
<div class="flex items-center gap-3">
<form method="POST" action="/api/subscribe">
<input type="hidden" name="priceId" value={item.priceId} />
<button
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"
>
Subscribe
</button>
</form>
</div>
{/if}
{/if}
</div>
</div>
</div>
-4
View File
@@ -1,4 +0,0 @@
/** @type {import('./$types').PageLoad} */
export function load({ params }) {
return params;
}
+15 -4
View File
@@ -1,10 +1,21 @@
import { json } from '@sveltejs/kit';
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
import { stripe as stripeClient } from '$lib/utils/stripe';
import Stripe from 'stripe';
import { type ProductInfo } from '$lib/types/Products/ProductInfo';
export const GET = async ({ params }) => {
const { productId } = params;
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 { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
import stripe from 'stripe';
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
import { stripe as stripeClient } from '$lib/utils/stripe';
export const GET = async ({ params }) => {
const { priceId } = params;
-16
View File
@@ -1,16 +0,0 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { type User } from '@supabase/auth-js';
export let data;
let { supabase, session } = data;
$: ({ supabase, session } = data);
</script>
<div class="h-screen">
{#if session?.user}
{session?.user?.email}
{:else}
<p>Please log in to view this page</p>
{/if}
</div>
@@ -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 };
};
+93
View File
@@ -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>
+29
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
<h1>Unauthorised Access</h1>
<p>Sorry, you don't have permission to access this tool.</p>
+52 -24
View File
@@ -5,11 +5,11 @@ project_id = "SvelteKitSaasBoilerplate"
[api]
enabled = true
# Port to use for the API URL.
port = 55321
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage", "graphql_public"]
# Extra schemas to add to the search_path of every request. public is always included.
# endpoints. `public` is always included.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request. `public` is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
@@ -17,9 +17,9 @@ max_rows = 1000
[db]
# Port to use for the local database URL.
port = 55322
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 55320
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
@@ -27,7 +27,7 @@ major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 55329
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
@@ -38,7 +38,7 @@ max_client_conn = 100
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
@@ -46,21 +46,19 @@ enabled = true
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 55323
port = 54323
# External URL of the API server that frontend connects to.
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
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 55324
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 55325
# pop3_port = 55326
# smtp_port = 54325
# pop3_port = 54326
[storage]
enabled = true
@@ -71,9 +69,9 @@ file_size_limit = "50MiB"
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:3000"
site_url = "http://localhost:5173"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["http://localhost: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).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
@@ -83,6 +81,8 @@ enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
@@ -94,11 +94,22 @@ enable_signup = true
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Use a production-ready SMTP server
# [auth.email.smtp]
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.email.template.magic_link]
subject = "Sign in to your account"
content_path = "./supabase/templates/magic_link.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
@@ -107,17 +118,25 @@ enable_signup = true
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }} ."
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
[auth.sms.test_otp]
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
[auth.hook.custom_access_token]
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
@@ -139,11 +158,20 @@ redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload, or `per_worker` for load testing.
policy = "oneshot"
inspector_port = 8083
[analytics]
enabled = false
port = 55327
vector_port = 55328
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
@@ -1,109 +0,0 @@
create table "public"."profiles" (
"id" uuid not null,
"updated_at" timestamp with time zone,
"created_at" timestamp with time zone default now(),
"username" text,
"full_name" text
);
alter table "public"."profiles" enable row level security;
CREATE UNIQUE INDEX profiles_pkey ON public.profiles USING btree (id);
CREATE UNIQUE INDEX profiles_username_key ON public.profiles USING btree (username);
alter table "public"."profiles" add constraint "profiles_pkey" PRIMARY KEY using index "profiles_pkey";
alter table "public"."profiles" add constraint "profiles_id_fkey" FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE not valid;
alter table "public"."profiles" validate constraint "profiles_id_fkey";
alter table "public"."profiles" add constraint "profiles_username_key" UNIQUE using index "profiles_username_key";
alter table "public"."profiles" add constraint "username_length" CHECK ((char_length(username) >= 3)) not valid;
alter table "public"."profiles" validate constraint "username_length";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
begin
insert into public.profiles (id, full_name)
values (new.id, new.raw_user_meta_data->>'full_name');
return new;
end;
$function$
;
grant delete on table "public"."profiles" to "anon";
grant insert on table "public"."profiles" to "anon";
grant references on table "public"."profiles" to "anon";
grant select on table "public"."profiles" to "anon";
grant trigger on table "public"."profiles" to "anon";
grant truncate on table "public"."profiles" to "anon";
grant update on table "public"."profiles" to "anon";
grant delete on table "public"."profiles" to "authenticated";
grant insert on table "public"."profiles" to "authenticated";
grant references on table "public"."profiles" to "authenticated";
grant select on table "public"."profiles" to "authenticated";
grant trigger on table "public"."profiles" to "authenticated";
grant truncate on table "public"."profiles" to "authenticated";
grant update on table "public"."profiles" to "authenticated";
grant delete on table "public"."profiles" to "service_role";
grant insert on table "public"."profiles" to "service_role";
grant references on table "public"."profiles" to "service_role";
grant select on table "public"."profiles" to "service_role";
grant trigger on table "public"."profiles" to "service_role";
grant truncate on table "public"."profiles" to "service_role";
grant update on table "public"."profiles" to "service_role";
create policy "Public profiles are viewable by everyone."
on "public"."profiles"
as permissive
for select
to public
using (true);
create policy "Users can insert their own profile."
on "public"."profiles"
as permissive
for insert
to public
with check ((auth.uid() = id));
create policy "Users can update own profile."
on "public"."profiles"
as permissive
for update
to public
using ((auth.uid() = id));
@@ -1,4 +0,0 @@
-- trigger the function every time a user is created
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
@@ -0,0 +1,155 @@
/**
* 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,
name text
);
alter table users enable row level security;
create policy "Can view own user data." on users for select using (auth.uid() = id);
create policy "Can update own user data." on users for update using (auth.uid() = id);
/**
* This trigger automatically creates a user entry when a new user signs up via Supabase Auth.
*/
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.users (id, name)
values (new.id, new.raw_user_meta_data->>'name');
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,
-- URLs of the product images in Stripe, meant to be displayable to the customer.
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.
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 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 product that this price belongs to.
product_id text not null 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', 'paused');
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 product this subscription is for.
product_id text references products(id) not null,
-- 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 (auth.uid() = user_id);
-- Purchases table to record product purchases.
create table purchases (
id uuid default uuid_generate_v4() primary key,
user_id uuid references auth.users not null,
product_id text references products(id) not null,
price_id text references prices(id) not null,
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;
$$;
+6
View File
@@ -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
View File
@@ -12,7 +12,7 @@
"moduleResolution": "bundler",
"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
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+2 -1
View File
@@ -1,6 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
export default defineConfig({
plugins: [sveltekit()]
plugins: [sveltekit(), purgeCss()]
});