1
0
mirror of https://github.com/tabler/tabler.git synced 2025-12-21 17:34:25 +04:00

Compare commits

...

56 Commits

Author SHA1 Message Date
tabler.developer@gmail.com
95891cb108 remove old lemon squeezy integration 2023-12-07 02:07:01 +01:00
tabler.developer@gmail.com
09a8e2d2c5 lemon squeezy integration 2023-12-07 00:56:02 +01:00
tabler.developer@gmail.com
e4c76be517 fix lemon squeezy integration 2023-11-12 23:21:31 +01:00
tabler.developer@gmail.com
9bbdba9c67 add lemon squeezy webhooks 2023-11-08 01:39:58 +01:00
tabler.developer@gmail.com
4ef2d125c2 WIP add lemon squeezy webhooks 2023-10-26 22:12:50 +02:00
tabler.developer@gmail.com
7193a70102 pricing cards for lemon squeezy products 2023-10-20 02:00:26 +02:00
tabler.developer@gmail.com
442ac3bb4b fetch lemon squezy products 2023-10-09 00:44:43 +02:00
tabler.developer@gmail.com
877182140d Merge branch 'dev' into dev-site-entry 2023-10-08 14:34:08 +02:00
BG-Software
80f5732d1a Change <h1> to <div> in navbar-logo.html (#1733)
Co-authored-by: Bartłomiej Gawęda <bgaweda@abis.krakow.pl>
2023-10-04 10:26:12 +02:00
Robert-Jan de Dreu
4dada97651 Update bug_report.yml 2023-09-28 10:03:38 +02:00
Robert-Jan de Dreu
dd7547a9a9 Update bug_report.yml 2023-09-28 10:02:25 +02:00
Robert-Jan de Dreu
1d24683563 Update bug_report.yml 2023-09-28 09:59:23 +02:00
Robert-Jan de Dreu
7a138cf02e Update bug_report.yml 2023-09-28 09:57:14 +02:00
Robert-Jan de Dreu
9500a0a0b0 Update and rename bug_report.md to bug_report.yml 2023-09-28 09:56:10 +02:00
Robert-Jan de Dreu
274f6433d0 Update bug_report.md 2023-09-28 09:51:27 +02:00
Robert-Jan de Dreu
4a4fc50127 Add extra description to bug_report.md 2023-09-28 09:44:55 +02:00
Robert-Jan de Dreu
b09d280fbe Improve bug_report.md to make sure more info is added 2023-09-28 09:40:11 +02:00
codecalm
efea7e0f7c fix #1724 - vertical center error pages 2023-09-25 21:56:50 +02:00
codecalm
9ea00ed58b Merge branch 'dev' of https://github.com/tabler/tabler into dev 2023-09-25 21:47:53 +02:00
codecalm
981d69baec navbar menu fix 2023-09-25 21:47:52 +02:00
codecalm
69449024e8 navbar menu fix 2023-09-25 21:47:31 +02:00
codecalm
6975ab5956 update jekyll 2023-09-25 21:38:41 +02:00
Jacob Tobiasz
b203b9c1a4 Remove ams and es-ct from the countries list (#1718) 2023-09-25 21:01:01 +02:00
codecalm
77c5127446 Merge branch 'site' of https://github.com/tabler/tabler into dev 2023-09-25 20:38:25 +02:00
codecalm
1831c45d88 update next 2023-09-25 20:32:19 +02:00
BG-Software
e6e5ffc544 Add background-clip: border-box to .dropdown-menu class (#1719)
Fixes transparent scrollbar background in dropdown-menu.

Co-authored-by: Bartłomiej Gawęda <bgaweda@abis.krakow.pl>
2023-09-24 09:02:12 +02:00
BG-Software
e639a20353 Fix replacing href="#" with href="javascript:void(0)" (#1720)
Replaces before using example variable.
Replace all hrefs, not only in <a> tags.

Co-authored-by: Bartłomiej Gawęda <bgaweda@abis.krakow.pl>
2023-09-23 14:02:12 +02:00
codecalm
1c78a7d705 Merge branch 'site' of https://github.com/tabler/tabler into dev 2023-09-22 23:03:49 +02:00
codecalm
4da52d719e update icons to v2.35.0 2023-09-22 23:01:53 +02:00
BG-Software
f3cfcc4fc1 Fix disabled css class for links (#1715)
Co-authored-by: Bartłomiej Gawęda <bgaweda@abis.krakow.pl>
2023-09-21 23:02:48 +02:00
Matt Holt
9fb40197b9 Fix some missing variables, minor tweaks to some colors (#1684) 2023-09-21 23:02:16 +02:00
BG-Software
f2e182dedf Fix heights, scrolls and layouts of some examples in Docs (#1705)
Co-authored-by: Mustafa Ateş Uzun <mustafauzun0@gmail.com>
Co-authored-by: Aditi Deshpande <aditideshpande2908@gmail.com>
Co-authored-by: Bartłomiej Gawęda <bgaweda@abis.krakow.pl>
2023-09-21 23:01:38 +02:00
BG-Software
2d0b051e26 Fix flags in preview (#1712)
Co-authored-by: Bartłomiej Gawęda <bgaweda@abis.krakow.pl>
2023-09-21 23:00:28 +02:00
codecalm
c462b74773 update icons to v2.34.0 2023-09-19 14:37:17 +02:00
Aditi Deshpande
dfbef814cd Updated the spelling of prev to previous in the file (#1700) 2023-09-14 11:11:23 +02:00
tabler.developer@gmail.com
9772160071 fix build 2023-09-09 01:46:05 +02:00
tabler.developer@gmail.com
aeff172a41 init subscriptions 2023-09-09 01:29:20 +02:00
Mustafa Ateş Uzun
5a2123fa6c Update _config.yml (#1677) 2023-09-08 11:11:20 +02:00
codecalm
2931c72341 update icons to v2.33.0 2023-09-05 19:41:33 +02:00
tabler.developer@gmail.com
7e62c3a563 add navigation auth 2023-08-31 02:11:31 +02:00
tabler.developer@gmail.com
d673851db5 replace credentails auth with auth0 2023-08-28 00:16:54 +02:00
tabler.developer@gmail.com
020255f161 auth error handling 2023-08-27 00:44:28 +02:00
codecalm
ec345edd9d test 2023-08-24 12:06:24 +02:00
tabler.developer@gmail.com
5edc93384c fix error message 2023-08-21 22:30:49 +02:00
tabler.developer@gmail.com
0efbb01e55 fix package.json 2023-08-20 22:33:23 +02:00
tabler.developer@gmail.com
361e81e478 postinstall prisma generate 2023-08-20 22:25:44 +02:00
tabler.developer@gmail.com
ebda434060 add prisma generate 2023-08-20 22:03:49 +02:00
tabler.developer@gmail.com
8ffe0e6a1a auth fixes 2023-08-20 20:49:48 +02:00
tabler.developer@gmail.com
5250158600 remove nextauth url 2023-08-20 16:51:43 +02:00
tabler.developer@gmail.com
e307ba44fb add credentials provider 2023-08-20 16:43:59 +02:00
tabler.developer@gmail.com
8cf5058456 add google-brand icon 2023-08-15 21:50:59 +02:00
tabler.developer@gmail.com
b7c772ce1b styles fix 2023-08-15 21:18:11 +02:00
tabler.developer@gmail.com
1f0e6e074a add google provider 2023-08-15 16:26:15 +02:00
tabler.developer@gmail.com
a76df72359 prisma and auth init 2023-08-15 00:30:16 +02:00
tabler.developer@gmail.com
90f4931c96 tabler signup and signin 2023-08-12 18:05:13 +02:00
tabler.developer@gmail.com
33bbc46229 init tabler site entry 2023-08-08 23:09:36 +02:00
89 changed files with 3572 additions and 466 deletions

View File

@@ -1,30 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**Device**
- Browser [e.g. Chrome ver.22, Safari ver.10]
- OS: [e.g. Windows 10]
- Screen size [e.g. 800x600]
**To reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
**Screenshots**
If applicable, add screenshots to help explain this problem.

72
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Bug report
description: Create a report to help us improve
title: "[BUG] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: "## Thank you for making a bug report!"
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug! It's really important to fill this form out completely as
not filling it out will make the bug reports hard to replicate.
- type: input
id: browser
attributes:
label: Browser
description: "What browser and version did this bug occur on?"
placeholder: "e.g. Chrome ver.22, Safari ver.10"
validations:
required: true
- type: input
id: os
attributes:
label: OS
description: "What is the operating system of your device?"
placeholder: "e.g. Windows 10, iOS 14, Ubuntu 23.04"
validations:
required: true
- type: input
id: screen_size
attributes:
label: Screen size
description: "What is the screen size of your device?"
placeholder: "e.g. 800x600, 1920x1080"
validations:
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: "A clear and concise description of what the bug is."
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: How to reproduce
description: "How do you trigger this bug? Please walk us through it step by step."
value: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
...
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: "If applicable, add screenshots here to help explain this problem. This helps us understand whats happening better."
validations:
required: false
- type: input
id: jsfiddle
attributes:
label: JSFiddle
description: "Please add a jsFiddle replicating the bug. Without the jsFiddle most bug reports cannot be solved and will be closed."
validations:
required: false
---

View File

@@ -2,7 +2,6 @@
All notable changes to this project will be documented in this file.
## `1.0.0-beta20` - 2023-08-24
- Update `bootstrap` to v5.3.1

View File

@@ -1,6 +1,6 @@
source "https://rubygems.org"
gem "jekyll", "4.3.1"
gem "jekyll", "4.3.2"
group :jekyll_plugins do
gem "jekyll-random"

View File

@@ -1,22 +1,23 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.1)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
colorator (1.1.0)
concurrent-ruby (1.1.10)
concurrent-ruby (1.2.2)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
eventmachine (1.2.7)
ffi (1.15.5)
ffi (1.16.2)
forwardable-extended (2.6.0)
google-protobuf (3.24.3)
htmlbeautifier (1.4.2)
htmlcompressor (0.4.0)
http_parser.rb (0.8.0)
i18n (1.12.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
jekyll (4.3.1)
jekyll (4.3.2)
addressable (~> 2.4)
colorator (~> 1.0)
em-websocket (~> 0.5)
@@ -36,13 +37,13 @@ GEM
jekyll (>= 3.3, < 5.0)
jekyll-redirect-from (0.16.0)
jekyll (>= 3.3, < 5.0)
jekyll-sass-converter (2.2.0)
sassc (> 2.0.1, < 3.0)
jekyll-sass-converter (3.0.0)
sass-embedded (~> 1.54)
jekyll-tidy (0.2.2)
htmlbeautifier
htmlcompressor
jekyll
jekyll-timeago (0.14.0)
jekyll-timeago (0.15.0)
mini_i18n (>= 0.8.0)
jekyll-watch (2.2.1)
listen (~> 3.0)
@@ -50,37 +51,39 @@ GEM
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
liquid (4.0.3)
listen (3.7.1)
liquid (4.0.4)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
mercenary (0.4.0)
mini_i18n (0.8.0)
mini_i18n (0.9.0)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (5.0.0)
public_suffix (5.0.3)
rake (13.0.6)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rexml (3.2.5)
rouge (3.30.0)
rexml (3.2.6)
rouge (4.1.3)
safe_yaml (1.0.5)
sassc (2.4.0)
ffi (~> 1.9)
sass-embedded (1.68.0)
google-protobuf (~> 3.23)
rake (>= 13.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
unicode-display_width (2.3.0)
webrick (1.7.0)
unicode-display_width (2.4.2)
webrick (1.8.1)
PLATFORMS
ruby
DEPENDENCIES
jekyll (= 4.3.1)
jekyll (= 4.3.2)
jekyll-random
jekyll-redirect-from
jekyll-tidy
jekyll-timeago
BUNDLED WITH
2.1.4
2.4.19

View File

@@ -222,7 +222,7 @@ socials:
title: Tabler
months-short: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
months-long: ['January', 'Febuary', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
months-long: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
icons:
link: https://tabler-icons.io
@@ -230,4 +230,4 @@ icons:
emails:
price: "$29"
count: 54
buy_link: https://tabler.io/buy-emails
buy_link: https://tabler.io/buy-emails

View File

@@ -137,7 +137,6 @@ Tabler has been optimized to correctly display content in any language. It suppo
```html example vertical centered columns={2}
<p>汉字</p>
<p>日本語の表記体系</p>
<p>한글</p>
<p>Кириллица</p>
<p>Eλληνική</p>
<p>ქართული დამწერლობა</p>
@@ -154,9 +153,6 @@ Tabler has been optimized to correctly display content in any language. It suppo
<h5>Japanese</h5>
<p>日本語の表記体系</p>
<h5>Korean</h5>
<p>한글</p>
<h5>Cyrillic</h5>
<p>Кириллица</p>

View File

@@ -8,7 +8,7 @@ bootstrapLink: components/alerts/
Depending on the information you need to convey, you can use one of the following types of alert messages - **success**, **info**, **warning** or **danger**. Using the right type of alert modal will help draw users' attention to the message and prompt them to take action.
```html example vertical
```html example vertical height="420px"
<div class="alert alert-success" role="alert">
<h4 class="alert-title">Wow! Everything worked!</h4>
<div class="text-secondary">Your account has been saved!</div>
@@ -38,7 +38,7 @@ Depending on the information you need to convey, you can use one of the followin
Add a link to your alert message to redirect users to the details they need to complete or additional information they should read.
```html example vertical code
```html example vertical code height="7rem"
<div class="alert alert-danger m-0">
This is a danger alert — <a href="#" class="alert-link">check it out</a>!
</div>
@@ -48,7 +48,7 @@ Add a link to your alert message to redirect users to the details they need to c
Add the `x` close button to make an alert modal dismissible. Thanks to that, your alert modal will disappear only once the user closes it.
```html example
```html example height="420px"
<div class="alert alert-success alert-dismissible" role="alert">
<div class="d-flex">
<div>
@@ -136,7 +136,7 @@ Add the `x` close button to make an alert modal dismissible. Thanks to that, you
Add an icon to your alert modal to make it more user-friendly and help users easily identify the message.
```html example
```html example height="420px"
<div class="alert alert-success" role="alert">
<div class="d-flex">
<div>
@@ -255,7 +255,7 @@ Add an icon to your alert modal to make it more user-friendly and help users eas
Add an avatar to your alert modal to make it more personalized.
```html code example
```html code example height="420px"
<div class="alert alert-success" role="alert">
<div class="d-flex">
<div>
@@ -302,7 +302,7 @@ Add an avatar to your alert modal to make it more personalized.
Add primary and secondary buttons to your alert modals if you want users to take a particular action based on the information included in the modal message.
```html code example
```html code example height="500px" scrollable
<div class="alert alert-success alert-dismissible" role="alert">
<h3 class="mb-1">Some Title</h3>
<p>Lorem ipsum Minim ad pariatur eiusmod ea ut nulla aliqua est quis id dolore minim voluptate.</p>
@@ -345,7 +345,7 @@ Add primary and secondary buttons to your alert modals if you want users to take
If you want your alert to be really eye-catching, you can add a class `alert-important`.
```html example vertical height="20rem"
```html example vertical height="210px"
<div class="alert alert-important alert-success alert-dismissible" role="alert">
<div class="d-flex">
<div>

View File

@@ -10,7 +10,7 @@ bootstrapLink: components/badge/
The default badges are square and come in the basic set of colors.
```html code example centered separated
```html code example vertical centered separated scrollable height="15rem"
<span class="badge bg-blue">Blue</span>
<span class="badge bg-azure">Azure</span>
<span class="badge bg-indigo">Indigo</span>
@@ -27,7 +27,7 @@ The default badges are square and come in the basic set of colors.
## Headings
```html code example
```html code example height="240px"
<h1>Example heading <span class="badge bg-secondary">New</span></h1>
<h2>Example heading <span class="badge bg-secondary">New</span></h2>
<h3>Example heading <span class="badge bg-secondary">New</span></h3>
@@ -38,7 +38,7 @@ The default badges are square and come in the basic set of colors.
## Outline badges
```html code example centered separated
```html code example vertical centered separated scrollable height="15rem"
<span class="badge badge-outline text-blue">blue</span>
<span class="badge badge-outline text-azure">azure</span>
<span class="badge badge-outline text-indigo">indigo</span>
@@ -57,7 +57,7 @@ The default badges are square and come in the basic set of colors.
Use the `.badge-pill` class if you want to create a badge with rounded corners. Its width will adjust to the label text.
```html code example centered separated
```html code example centered separated height="7rem"
<span class="badge badge-pill bg-blue">1</span>
<span class="badge badge-pill bg-azure">2</span>
<span class="badge badge-pill bg-indigo">3</span>
@@ -76,7 +76,7 @@ Use the `.badge-pill` class if you want to create a badge with rounded corners.
You can create a soft colour variant of a corresponding contextual badge variation, to make it look more subtle. Click [here](colors) to see the list of available colors and choose ones that best suit your design.
```html code example centered separated
```html code example vertical centered separated scrollable height="15rem"
<span class="badge bg-blue-lt">Blue</span>
<span class="badge bg-azure-lt">Azure</span>
<span class="badge bg-indigo-lt">Indigo</span>
@@ -95,7 +95,7 @@ You can create a soft colour variant of a corresponding contextual badge variati
Place the badge within an `<a>` element if you want it to perform the function of a link and make it clickable.
```html code example centered separated
```html code example vertical centered separated scrollable height="15rem"
<a href="#" class="badge bg-blue">Blue</a>
<a href="#" class="badge bg-azure">Azure</a>
<a href="#" class="badge bg-indigo">Indigo</a>
@@ -114,7 +114,7 @@ Place the badge within an `<a>` element if you want it to perform the function o
Badges can be used as part of links or buttons to provide a counter.
```html example centered separated
```html example centered separated height="7rem"
<button type="button" class="btn">Notifications <span class="badge bg-red ms-2">4</span></button>
<button type="button" class="btn">Notifications <span class="badge bg-green ms-2">4</span></button>
```

View File

@@ -8,7 +8,7 @@ bootstrapLink: components/buttons/
As one of the most common elements of UI design, buttons have a very important function of engaging users with your website or app and guiding them in their actions. Use the `.btn` classes with the `<button>` element and add additional styling that will make your buttons serve their purpose and draw users' attention.
```html example code centered separated
```html example code centered separated height="7rem"
<a href="#" class="btn" role="button">Link</a>
<button class="btn">Button</button>
<input type="button" class="btn" value="Input" />
@@ -20,7 +20,7 @@ As one of the most common elements of UI design, buttons have a very important f
The standard button creates a white background and subtle hover animation. It's meant to look and behave as an interactive element of your page.
```html example code centered separated
```html example code centered separated height="7rem"
<a href="#" class="btn" role="button">Link</a>
```
@@ -28,7 +28,7 @@ The standard button creates a white background and subtle hover animation. It's
Use the button classes that correspond to the function of your button. The big range of available colors will help you show your buttons' purpose and make them easy to spot.
```html example code centered separated
```html code example vertical centered separated scrollable height="15rem"
<a href="#" class="btn btn-primary">Primary</a>
<a href="#" class="btn btn-secondary">Secondary</a>
<a href="#" class="btn btn-success">Success</a>
@@ -43,7 +43,7 @@ Use the button classes that correspond to the function of your button. The big r
Make buttons look inactive to show that an action is possible once the user meets certain criteria, such as completing the required fields to submit a form.
```html example code centered separated
```html code example vertical centered separated scrollable height="15rem"
<a href="#" class="btn btn-primary disabled">Primary</a>
<a href="#" class="btn btn-secondary disabled">Secondary</a>
<a href="#" class="btn btn-success disabled">Success</a>
@@ -58,7 +58,7 @@ Make buttons look inactive to show that an action is possible once the user meet
Choose the right color for your button to make it go well with your design and draw users' attention. Button colors can have a big influence on users' decisions, which is why it's important to choose them based on the intended purpose.
```html example code centered separated
```html code example vertical centered separated scrollable height="15rem"
<a href="#" class="btn btn-blue">Blue</a>
<a href="#" class="btn btn-azure">Azure</a>
<a href="#" class="btn btn-indigo">Indigo</a>
@@ -77,7 +77,7 @@ Choose the right color for your button to make it go well with your design and d
Use the `.btn-ghost-*` class to make your button look simple yet aesthetically appealing. Ghost buttons help focus users' attention on the website's primary design, at the same time encouraging them to take action.
```html example centered separated
```html example vertical centered separated scrollable height="15rem"
<a href="#" class="btn btn-ghost-primary">Primary</a>
<a href="#" class="btn btn-ghost-secondary">Secondary</a>
<a href="#" class="btn btn-ghost-success">Success</a>
@@ -103,7 +103,7 @@ Use the `.btn-ghost-*` class to make your button look simple yet aesthetically a
Use the `.btn-square` class to remove the border radius, if you want the corners of your button to be square rather than rounded.
```html example centered separated
```html example centered separated height="7rem"
<a href="#" class="btn btn-square">Square button</a>
```
@@ -117,7 +117,7 @@ Use the `.btn-square` class to remove the border radius, if you want the corners
Add the `.btn-pill` class to your button to make it rounded and give it a modern and attractive look.
```html example centered separated
```html example centered separated height="7rem"
<a href="#" class="btn btn-pill">Pill button</a>
```
@@ -131,7 +131,7 @@ Add the `.btn-pill` class to your button to make it rounded and give it a modern
Replace the default modifier class with the `.btn-outline-*` class, if you want to remove the color and the background of your button and give it a more subtle look. Outline buttons are perfect to use as secondary buttons, as they don't distract users from the main action.
```html example centered separated
```html example vertical centered separated scrollable height="15rem"
<a href="#" class="btn btn-outline-primary">Primary</a>
<a href="#" class="btn btn-outline-secondary">Secondary</a>
<a href="#" class="btn btn-outline-success">Success</a>
@@ -173,12 +173,12 @@ Replace the default modifier class with the `.btn-outline-*` class, if you want
Add `.btn-lg` or `.btn-sm` to change the size of your button and differentiate those which should have primary focus from those of secondary importance. Adapt the button size to your design and encourage users to take actions.
```html code example centered separated
```html code example centered separated height="8rem"
<button type="button" class="btn btn-primary btn-lg">Large button</button>
<button type="button" class="btn btn-lg">Large button</button>
```
```html code example centered separated
```html code example centered separated height="7rem"
<button type="button" class="btn btn-primary btn-sm">Small button</button>
<button type="button" class="btn btn-sm">Small button</button>
```
@@ -189,7 +189,7 @@ Label your button with text and add an icon to communiacate the action and make
Icons can be found [**here**](/docs/components/icons)
```html example centered separated
```html example centered separated height="7rem"
<button type="button" class="btn"><svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
@@ -260,7 +260,7 @@ Icons can be found [**here**](/docs/components/icons)
You can use the icons of popular social networking sites, which users are familiar with. Thanks to buttons with social media icons users can share content or follow a website with just one click, without leaving the website.
```html example centered separated
```html example vertical centered separated scrollable height="15rem"
<a href="#" class="btn btn-facebook"><svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3" />
@@ -408,7 +408,7 @@ You can use the icons of popular social networking sites, which users are famili
You can also add an icon without the name of a social networking site, if you want to display more buttons on a small space.
```html example centered separated
```html example separated scrollable height="7rem"
<a href="#" class="btn btn-facebook btn-icon" aria-label="Button">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -572,7 +572,7 @@ You can also add an icon without the name of a social networking site, if you wa
Add the `.btn-icon` class to remove unnecessary padding from your button and use an icon without any additional label. Thanks to that, you can save space and make the action easy to recognize for international users.
```html example centered separated
```html example centered separated height="7rem"
<a href="#" class="btn btn-primary btn-icon" aria-label="Button">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -664,7 +664,7 @@ Add the `.btn-icon` class to remove unnecessary padding from your button and use
Create a dropdown button that will encourage users to click for more options. You can add a label with an icon or remove the label and add an icon on its own if you want to save space. Choose the option that will best suit your design and improve the user experience.
```html example centered separated
```html example centered separated height="7rem"
<div class="dropdown">
<button type="button" class="btn dropdown-toggle" data-bs-toggle="dropdown"><svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
@@ -753,7 +753,7 @@ Create a dropdown button that will encourage users to click for more options. Yo
Add the `.btn-loading` class to show a button's loading state, which can be useful in the case of operations that take longer to process. Thanks to that, users will be aware of the current state of their action and won't give it up before it's finished.
```html example centered separated
```html example centered separated height="7rem"
<a href="#" class="btn btn-primary btn-loading">Button</a>
<a href="#" class="btn btn-primary btn-loading">Loading button with loooong content</a>
```
@@ -767,7 +767,7 @@ Add the `.btn-loading` class to show a button's loading state, which can be usef
</a>
```
```html example
```html example centered height="7rem"
<a href="#" class="btn btn-primary"><span class="spinner-border spinner-border-sm me-2" role="status"></span> Button</a>
```
@@ -782,7 +782,7 @@ Add the `.btn-loading` class to show a button's loading state, which can be usef
Create a list of buttons using the `.btn-list` container to display different actions a user can take. If you add aditional styling, such as colours, you will be able to focus users' attention on a particular action or suggest the result.
```html code example vertical centered columns={3}
```html code example vertical centered columns={3} height="7rem"
<div class="btn-list">
<a href="#" class="btn btn-success">Save changes</a>
<a href="#" class="btn">Save and continue</a>
@@ -818,21 +818,21 @@ If the list is long, it will be wrapped and some buttons will be moved to the ne
Use the `.text-center` or the `.text-end` modifiers to change the buttons' alignment and place them where they suit best.
```html code example vertical centered columns={3}
```html code example vertical centered columns={3} height="7rem"
<div class="btn-list justify-content-center">
<a href="#" class="btn">Save and continue</a>
<a href="#" class="btn btn-primary">Save changes</a>
</div>
```
```html code example vertical centered columns={3}
```html code example vertical centered columns={3} height="7rem"
<div class="btn-list justify-content-end">
<a href="#" class="btn">Save and continue</a>
<a href="#" class="btn btn-primary">Save changes</a>
</div>
```
```html code example vertical centered columns={3}
```html code example vertical centered columns={3} height="7rem"
<div class="btn-list">
<a href="#" class="btn btn-outline-danger me-auto">Delete</a>
<a href="#" class="btn">Save and continue</a>
@@ -844,7 +844,7 @@ Use the `.text-center` or the `.text-end` modifiers to change the buttons' align
Use buttons with avatars to simplify the process of interaction and make your design more personalized. Buttons can contain avatars and labels or only avatars, if displayed on a smaller space.
```html example centered separated
```html example centered separated height="7rem"
<a href="#" class="btn"><span class="avatar" style={{ backgroundImage: 'url(https://images.unsplash.com/photo-1542534759-05f6c34a9e63?q=80&fm=jpg&crop=faces&fit=crop&h=100&w=100)'}}></span> Avatar</a>
<a href="#" class="btn"><span class="avatar" style={{ backgroundImage: 'url(https://images.unsplash.com/photo-1546539782-6fc531453083?q=80&fm=jpg&crop=faces&fit=crop&h=100&w=100)'}}></span> Avatar</a>
<a href="#" class="btn"><span class="avatar" style={{ backgroundImage: 'url(https://images.unsplash.com/photo-1541585452861-0375331f10bf?q=80&fm=jpg&crop=faces&fit=crop&h=100&w=100)'}}></span> Avatar</a>

View File

@@ -125,7 +125,7 @@ Add an image to your blog post card to make it eye-catching. You can do it by ad
Add the `.row-deck` class to `.row`, if you want to display several cards next to one another. Thanks to that, they will all have the same height.
```html code example centered
```html code example centered height="220px"
<div class="row row-deck">
<div class="col-md-4">
<div class="card">

View File

@@ -3,7 +3,7 @@ title: Data grid
description: Use the data grid component to display detailed information about your product. The data is displayed as a column of items consisting of a title and content.
---
```html example code vcentered height="22rem"
```html example code vcentered height="460px"
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Registrar</div>

View File

@@ -22,7 +22,7 @@ Use dividers to visually separate content into parts. You can use a line only or
You can modify the position of the text which is to be included in a separator and make it left- or right-aligned. Otherwise, the text will remain centered.
```html code example
```html code example height="380px"
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Alias, dolore dolores doloribus est ex.
</p>
@@ -44,7 +44,7 @@ You can modify the position of the text which is to be included in a separator a
Customize the color of dividers to make them go well with your design. Click [here](colors) to see the list of available colors.
```html code example
```html code example height="380px"
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Alias, dolore dolores doloribus est ex.
</p>

View File

@@ -8,7 +8,7 @@ bootstrapLink: components/dropdowns
With small markup changes, you can turn any `.btn` into a dropdown toggle and use it to display more options for users to choose from. Start with the default dropdown and then use additional classes to make your dropdown more user-friendly.
```html example code height="20rem"
```html example code height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -23,7 +23,7 @@ With small markup changes, you can turn any `.btn` into a dropdown toggle and us
Use dropdown dividers to separate groups of dropdown items for greater clarity.
```html example code height="20rem"
```html example code height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -43,7 +43,7 @@ Use dropdown dividers to separate groups of dropdown items for greater clarity.
Make a dropdown item look active, so that it highlights when a user hovers over a given option.
```html example code height="20rem"
```html example code height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -62,7 +62,7 @@ Make a dropdown item look active, so that it highlights when a user hovers over
Make a dropdown item look disabled to display options which are currently not available but can activate once certain conditions are met.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -81,7 +81,7 @@ Make a dropdown item look disabled to display options which are currently not av
Add a dropdown header to group dropdown items into sections and name them accordingly.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -100,7 +100,7 @@ Add a dropdown header to group dropdown items into sections and name them accord
Use icons in your dropdowns to add more visual content and make the options easy to identify for users.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -129,7 +129,7 @@ Use icons in your dropdowns to add more visual content and make the options easy
Add an arrow that points at the dropdown button.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu dropdown-menu-arrow">
@@ -147,7 +147,7 @@ Add an arrow that points at the dropdown button.
Add a badge to your dropdown items to show additional information related to an item or distinguish it from other elements.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -167,7 +167,7 @@ Add a badge to your dropdown items to show additional information related to an
Use dropdowns with checkboxes to allow users to select options from a predefined list. Dropdowns with checkboxes are particularly useful for filtering.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu">
@@ -182,7 +182,7 @@ Use dropdowns with checkboxes to allow users to select options from a predefined
Make your dropdown suit the dark mode of your website or software.
```html code example height="20rem"
```html code example height="16rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu dropdown-menu-arrow bg-dark text-white">
@@ -215,7 +215,7 @@ Use a dropdown with card content to make it easy for users to get more informati
```html example height="35rem"
<div class="dropdown">
<a href="#" class="btn dropdown-toggle" data-bs-toggle="dropdown">Open dropdown</a>
<div class="dropdown-menu dropdown-menu-card" style="max-width: 20rem;">
<div class="dropdown-menu dropdown-menu-card" style="max-width: 16rem;">
<div class="card d-flex flex-column">
<a href="#">
<img class="card-img-top" src="/samples/photos/friends-at-a-restaurant-drinking-wine.jpg" alt="How do you know she is a witch?" />

View File

@@ -7,7 +7,7 @@ description: Empty states or blank pages are commonly used as placeholders for f
Use the default empty state to engage users in the critical moments of their experience with your website or app. A good empty state screen should let users know what is happening and what they should do next as well as encourage them to take action.
```html code example
```html code example height="300px"
<div class="empty">
<div class="empty-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
@@ -39,7 +39,7 @@ Use the default empty state to engage users in the critical moments of their exp
Make your empty state screen more attractive and engaging by adding an illustration. Thanks to a more personalized design, you will improve your brand image and make your website or app more user friendly.
```html code example
```html code example height="300px"
<div class="empty">
<div class="empty-img"><img src="..." height="128" alt="" />
</div>
@@ -64,7 +64,7 @@ Make your empty state screen more attractive and engaging by adding an illustrat
Instead of adding an icon or illustration you can simply give the text:
```html example
```html example height="300px"
<div class="empty">
<div class="empty-header">404</div>
<p class="empty-title">Oops… You just found an error page</p>

View File

@@ -6,7 +6,7 @@ description: A simple, lightweight, accessible and customizable HTML5, YouTube a
## Sample demo
```html example code vendors
```html example code vendors height="500px"
<div id="player-youtube" data-plyr-provider="youtube" data-plyr-embed-id="dQw4w9WgXcQ"></div>
<script src="$TABLER_CDN/dist/libs/plyr/dist/plyr.min.js"></script>
@@ -19,7 +19,7 @@ description: A simple, lightweight, accessible and customizable HTML5, YouTube a
## Vimeo file
```html example code vendors
```html example code vendors height="500px"
<div id="player-vimeo" data-plyr-provider="vimeo" data-plyr-embed-id="515937365"></div>
<script src="$TABLER_CDN/dist/libs/plyr/dist/plyr.min.js"></script>

View File

@@ -81,7 +81,7 @@ You can use a placeholder, which will look like a picture. You can use the `rati
You can also use the `ratio` component, and get the image in the right proportions.
```html code example columns={1}
```html code example columns={1} height={500} scrollable
<div class="ratio ratio-1x1 placeholder">
<div class="placeholder-image"></div>
</div>
@@ -100,7 +100,7 @@ You can also use the `ratio` component, and get the image in the right proportio
By default, the `placeholder` uses `currentColor`. This can be overridden with a custom color or utility class.
```html example columns={1}
```html example columns={1} height={240}
<span class="placeholder col-12"></span>
<span class="placeholder col-12 bg-primary"></span>
<span class="placeholder col-12 bg-secondary"></span>
@@ -153,7 +153,7 @@ Animate placeholders with `.placeholder-glow` or `.placeholder-wave` to better c
See in the following examples how else you can use the placeholder component
```html code example columns={1} height={1000} separated vertical
```html code example columns={1} height={1000} separated vertical scrollable
<div class="card placeholder-glow">
<div class="ratio ratio-21x9 card-img-top placeholder"></div>
<div class="card-body">

View File

@@ -9,7 +9,7 @@ All options and features can be found [**here**](https://refreshless.com/nouisli
## Basic range slider
```html code example
```html code example centered
<div data-slider='{"js-name": "slider0","start": 50,"range": {"min": 0,"max": 100}}'></div>
<p demo-slider="slider0"></p>
```

View File

@@ -5,7 +5,7 @@ description: Status dots are particularly useful if you want to make an interfac
## Default markup
```html example vertical
```html example vertical height="7rem"
<span class="status status-blue">Blue</span>
<span class="status status-azure">Azure</span>
<span class="status status-indigo">Indigo</span>
@@ -28,7 +28,7 @@ description: Status dots are particularly useful if you want to make an interfac
## Status with dot
```html example code vertical
```html example code vertical height="7rem"
<span class="status status-blue">
<span class="status-dot"></span>
Blue
@@ -81,7 +81,7 @@ description: Status dots are particularly useful if you want to make an interfac
### Animated dot
```html example code vertical
```html example code vertical height="7rem"
<span class="status status-blue">
<span class="status-dot status-dot-animated"></span>
Blue
@@ -134,7 +134,7 @@ description: Status dots are particularly useful if you want to make an interfac
## Lite status
```html example code vertical
```html example code vertical height="7rem"
<span class="status status-blue status-lite">
<span class="status-dot"></span>
Blue
@@ -187,7 +187,7 @@ description: Status dots are particularly useful if you want to make an interfac
## Status dots
```html example code vertical
```html code example centered separated height="7rem"
<span class="status-dot status-blue"></span>
<span class="status-dot status-azure"></span>
<span class="status-dot status-indigo"></span>
@@ -204,7 +204,7 @@ description: Status dots are particularly useful if you want to make an interfac
### Animated dots
```html example code vertical
```html code example centered separated height="7rem"
<span class="status-dot status-dot-animated status-blue"></span>
<span class="status-dot status-dot-animated status-azure"></span>
<span class="status-dot status-dot-animated status-indigo"></span>
@@ -221,7 +221,7 @@ description: Status dots are particularly useful if you want to make an interfac
## Status indicator
```html code example vertical
```html code example vertical centered height="7rem"
<span class="status-indicator status-blue status-indicator-animated">
<span class="status-indicator-circle"></span>
<span class="status-indicator-circle"></span>

View File

@@ -6,11 +6,11 @@ bootstrapLink: content/tables/
## Basic Table
The basic table design has light padding and the presented data is separated wih horizontal dividers. It helps provide users with all the necessary information, without overwheling them with visuals.
The basic table design has light padding and the presented data is separated wih horizontal dividers. It helps provide users with all the necessary information, without overwhelming them with visuals.
The `.table` class adds basic styling to a table:
```html code example
```html code example height="360px" scrollable
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
@@ -97,7 +97,7 @@ The `.table` class adds basic styling to a table:
Use the `.table-responsive` class across each breakpoint for horizontal scrolling tables. If you want to create responsive tables up to a specific breakpoint, use `.table-responsive{-sm|-md|-lg|-xl}`. From that breakpoint and up, the table will behave normally, rather than scroll horizontally.
```html code example
```html code example height="12rem"
<table class="table table-responsive">
<thead>
<tr>
@@ -149,7 +149,7 @@ Use the `.table-responsive` class across each breakpoint for horizontal scrollin
If you don't want the table cell content to wrap to another line, use the `table-nowrap` class.
```html example
```html example height="10rem"
<div class="table-responsive">
<table class="table table-vcenter table-nowrap">
<thead>
@@ -289,7 +289,7 @@ If you don't want the table cell content to wrap to another line, use the `table
## Table Variants
```html code example
```html code example height="360px" scrollable
<table class="table">
<thead>
<tr>

View File

@@ -7,7 +7,7 @@ description: A timeline is a perfect way to visualize processes and projects, as
The available timeline design is comprised of many components that will help you visualize a process or show an outline of events. Thanks to the possibility of adding icons, avatars and links and the way of presenting the elements of content, your timeline will be clear for users and will make yor website or app more attractive.
```html example
```html example height="400px" scrollable
<ul class="timeline">
<li class="timeline-event">
<div class="timeline-event-icon bg-twitter-lt">
@@ -258,7 +258,7 @@ The available timeline design is comprised of many components that will help you
Use a simplified version of the timeline, if it suits your design better. You can still make use of all the available timeline components.
```html example
```html example height="400px" scrollable
<ul class="timeline timeline-simple">
<li class="timeline-event">
<div class="timeline-event-icon bg-twitter-lt">

View File

@@ -44,7 +44,7 @@ Toasts blend over the elements they appear over. If a browser supports the `back
Stack multiple toasts together by putting them within one `.toast-container`.
```html example code
```html example code height="260px"
<div class="toast-container">
<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true" data-bs-autohide="false" data-bs-toggle="toast">
<div class="toast-header">

View File

@@ -8,7 +8,7 @@ bootstrapLink: components/tooltips/
Use the default markup to create tooltips that will help users understand particular elements of your interface. You can decide where the text label is to be displayed - at the top, bottom or on either side of the element.
```html code example
```html code example centered separated
<button type="button" class="btn" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">
Tooltip on top
</button>
@@ -27,7 +27,7 @@ Use the default markup to create tooltips that will help users understand partic
If the default tooltip is not enough, you can add the option to use HTML code in the text to highlight particular bits of information and make the content more attractive.
```html code example
```html code example height="7rem"
<button type="button" class="btn" data-bs-toggle="tooltip" data-bs-html="true" title="<em>Tooltip</em> <u>with</u> <b>HTML</b>">
Tooltip with HTML
</button>

View File

@@ -190,7 +190,7 @@
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"@tabler/icons": "^2.32.0",
"@tabler/icons": "^2.35.0",
"bootstrap": "5.3.1"
},
"peerDependencies": {

15
pnpm-lock.yaml generated
View File

@@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@melloware/coloris':
specifier: ^0.19.1
@@ -8,8 +12,8 @@ dependencies:
specifier: ^2.11.8
version: 2.11.8
'@tabler/icons':
specifier: ^2.32.0
version: 2.32.0
specifier: ^2.35.0
version: 2.35.0
bootstrap:
specifier: 5.3.1
version: 5.3.1(@popperjs/core@2.11.8)
@@ -1947,8 +1951,8 @@ packages:
defer-to-connect: 2.0.1
dev: true
/@tabler/icons@2.32.0:
resolution: {integrity: sha512-w1oNvrnqFipoBEy2/0X4/IHo2aLsijuz4QRi/HizxqiaoMfmWG5X2DpEYTw9WnGvFmixpu/rtQsQAr7Wr0Mc2w==}
/@tabler/icons@2.35.0:
resolution: {integrity: sha512-qW/itKdmFvfGw6mAQ+cZy+2MYTXb0XdGAVhO3obYLJEfsSPMwQRO0S9ckFk1xMQX/Tj7REC3TEmWUBWNi3/o3g==}
dev: false
/@tootallnate/once@1.1.2:
@@ -2718,6 +2722,7 @@ packages:
/bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
requiresBuild: true
dependencies:
file-uri-to-path: 1.0.0
dev: true
@@ -4319,6 +4324,7 @@ packages:
/file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
requiresBuild: true
dev: true
optional: true
@@ -6642,6 +6648,7 @@ packages:
/nan@2.17.0:
resolution: {integrity: sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==}
requiresBuild: true
dev: true
optional: true

View File

@@ -1,4 +1,4 @@
{
"presets": ["next/babel"],
"plugins": []
"plugins": ["@babel/plugin-transform-private-methods"]
}

View File

@@ -1,2 +1,47 @@
# App
NEXT_PUBLIC_APP_URL=http://localhost:3010
# * Create an .env file with the following content:
# Created by Vercel CLI
NEXT_PUBLIC_APP_URL=""
NX_DAEMON=""
TURBO_REMOTE_ONLY=""
TURBO_RUN_SUMMARY=""
VERCEL_ENV=""
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_COMMIT_MESSAGE=""
VERCEL_GIT_COMMIT_REF=""
VERCEL_GIT_COMMIT_SHA=""
VERCEL_GIT_PREVIOUS_SHA=""
VERCEL_GIT_PROVIDER=""
VERCEL_GIT_PULL_REQUEST_ID=""
VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_URL=""
# Postgres
POSTGRES_URL=""
POSTGRES_PRISMA_URL=""
POSTGRES_URL_NON_POOLING=""
POSTGRES_USER=""
POSTGRES_HOST=""
POSTGRES_PASSWORD=""
POSTGRES_DATABASE=""
# Providers
GITHUB_ID=""
GITHUB_SECRET=""
GOOGLE_ID=""
GOOGLE_SECRET=""
AUTH0_CLIENT_ID=""
AUTH0_CLIENT_SECRET=""
AUTH0_ISSUER=""
# Auth config
NEXTAUTH_SECRET=""
NEXTAUTH_URL=""
# Lemon squeezy
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_SIGNING_SECRET=""
LEMON_SQUEEZY_STORE_ID=""

View File

@@ -1,5 +1,5 @@
{
"extends": ["next/babel","next/core-web-vitals"],
"extends": ["babel"],
"rules": {
"react/no-unescaped-entities": "off",
"react/display-name": "off",

3
site/.gitignore vendored
View File

@@ -25,6 +25,7 @@ yarn-error.log*
# local env files
.env
.env.local
.env*.local
.env.development.local
.env.test.local
.env.production.local
@@ -47,4 +48,4 @@ _site
.contentlayer
public/static/tabler-icons/icons/*
public/static/tabler-icons/icons-png/*
public/static/tabler-icons/icons-png/*

View File

@@ -0,0 +1,18 @@
import EntryHeader from '@/components/layout/EntryHeader';
export default function CoreLayout({
children
}: {
children: React.ReactNode,
}) {
return (
<>
<EntryHeader />
<div className="page page-center">
<div className="container container-tight py-4">
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,10 @@
import Signin from '@/components/Signin';
export const metadata = {
title: 'Tabler Sign in',
description: 'Sign in to Tabler',
};
export default function SigninPage() {
return <Signin/>;
}

View File

@@ -0,0 +1,10 @@
import Signup from '@/components/Signup';
export const metadata = {
title: 'Tabler Sign up',
description: 'Sign up to Tabler',
};
export default function SignupPage() {
return <Signup/>;
}

View File

@@ -0,0 +1,41 @@
import { getSession } from '@/lib/auth'
import Link from 'next/link'
import { PlansComponent } from '@/components/Manage'
import { getPlans, getSubscription } from '@/lib/data'
import { redirect } from 'next/navigation'
export const metadata = {
title: 'Change plan'
}
export default async function Page() {
const session = await getSession()
const sub = await getSubscription(session?.user?.id)
if (!sub) {
redirect('/billing')
}
const plans = await getPlans()
return (
<section className="section">
<div className="container">
<h2 className="page-title text-center">Change plan</h2>
<Link href="/billing/" className="mb-6">&larr; Back to billing</Link>
{sub.status == 'on_trial' && (
<div className="my-8 p-4 h-subheader">
You are currently on a free trial. You will not be charged when changing plans during a trial.
</div>
)}
<PlansComponent plans={plans} sub={sub} />
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>
</div>
</section>
)
}

View File

@@ -0,0 +1,28 @@
import { getSession } from '@/lib/auth'
import { getPlans, getSubscription } from '@/lib/data'
/* Full in-app billing component */
import { SubscriptionComponent } from '@/components/Subscription'
export const metadata = {
title: 'Billing'
}
export default async function Page() {
const session = await getSession()
const plans = await getPlans()
const sub = await getSubscription(session?.user?.id)
return (
<section className="section">
<div className="container">
<h2 className="page-title text-center">Billing</h2>
<SubscriptionComponent sub={sub} plans={plans} />
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>
</div>
</section>
)
}

View File

@@ -0,0 +1,107 @@
import prisma from '@/lib/prisma'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
export const dynamic = 'force-dynamic' // Don't cache API results
async function getPlans() {
const params = { include: ['product'] as Array<'product' | 'files'>, perPage: 50 }
let hasNextPage = true;
let page = 1;
let variants = [] as {}[]
let products = [] as Record<string, any>
while (hasNextPage) {
const resp = await ls.getVariants(params);
variants = variants.concat(resp['data'])
products = products.concat(resp['included'])
if (resp['meta']['page']['lastPage'] > page) {
page += 1
params['page'] = page
} else {
hasNextPage = false
}
}
// Nest products inside variants
const prods = {};
for (let i = 0; i < products.length; i++) {
prods[products[i]['id']] = products[i]['attributes']
}
for (let i = 0; i < variants.length; i++) {
variants[i]['product'] = prods[variants[i]['attributes']['product_id']]
}
// Save locally
let variantId,
variant,
product,
productId
for (let i = 0; i < variants.length; i++) {
variant = variants[i]
if ( !variant['attributes']['is_subscription'] ) {
console.log('Not a subscription')
continue
}
if ( String(variant['product']['store_id']) !== process.env.LEMON_SQUEEZY_STORE_ID ) {
console.log(`Store ID ${variant['product']['store_id']} does not match (${process.env.LEMON_SQUEEZY_STORE_ID})`)
continue
}
variantId = parseInt(variant['id'])
product = variant['product']
productId = parseInt(variant['attributes']['product_id'])
// Get variant's Price objects
let prices = await ls.getPrices({ variantId: variantId, perPage: 100 })
// The first object is the latest/current price
let variant_price = prices['data'][0]['attributes']['unit_price']
variant = variant['attributes']
const updateData = {
productId: productId,
name: product['name'],
variantName: variant['name'],
status: variant['status'],
sort: variant['sort'],
description: variant['description'],
price: variant_price, // display price in the app matches current Price object in LS
interval: variant['interval'],
intervalCount: variant['interval_count'],
}
const createData = { ...updateData, variantId}
try {
await prisma.plan.upsert({
where: {
variantId: variantId
},
update: updateData,
create: createData
})
} catch (error) {
console.log(variant)
console.log(error)
}
}
}
export default async function Page() {
await getPlans()
return (
<p>
Done!
</p>
)
}

View File

@@ -0,0 +1,152 @@
import prisma from '@/lib/prisma'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
async function processEvent(event) {
let processingError = ''
const customData = event.body['meta']['custom_data'] || null
if (!customData || !customData['user_id']) {
processingError = 'No user ID, can\'t process'
} else {
const obj = event.body['data']
if (event.eventName.startsWith('subscription_payment_')) {
// Save subscription invoices; obj is a "Subscription invoice"
/* Not implemented */
} else if (event.eventName.startsWith('subscription_')) {
// Save subscriptions; obj is a "Subscription"
const data = obj['attributes']
// We assume the Plan table is up to date
const plan = await prisma.plan.findUnique({
where: {
variantId: data['variant_id']
},
})
if (!plan) {
processingError = 'Plan not found in DB. Could not process webhook event.'
} else {
// Update the subscription
const lemonSqueezyId = parseInt(obj['id'])
// Get subscription's Price object
// We save the Price value to the subscription so we can display it in the UI
let priceData = await ls.getPrice({ id: data['first_subscription_item']['price_id'] })
const updateData = {
orderId: data['order_id'],
name: data['user_name'],
email: data['user_email'],
status: data['status'],
renewsAt: data['renews_at'],
endsAt: data['ends_at'],
trialEndsAt: data['trial_ends_at'],
planId: plan['id'],
userId: customData['user_id'],
price: priceData['data']['attributes']['unit_price'],
subscriptionItemId: data['first_subscription_item']['id'],
// Save this for usage-based billing reporting; no need to if you use quantity-based billing
isUsageBased: data['first_subscription_item']['is_usage_based'],
}
const createData = { ...updateData, lemonSqueezyId}
createData.price = plan.price
try {
// Create/update subscription
await prisma.subscription.upsert({
where: {
lemonSqueezyId: lemonSqueezyId
},
update: updateData,
create: createData,
})
} catch (error) {
processingError = error
console.log(error)
}
}
} else if (event.eventName.startsWith('order_')) {
// Save orders; obj is a "Order"
/* Not implemented */
} else if (event.eventName.startsWith('license_')) {
// Save license keys; obj is a "License key"
/* Not implemented */
}
try {
// Mark event as processed
await prisma.webhookEvent.update({
where: {
id: event.id
},
data: {
processed: true,
processingError
}
})
} catch (error) {
console.log(error)
}
}
}
export async function POST(request: Request) {
// Make sure request is from Lemon Squeezy
const crypto = require('crypto')
const rawBody = await request.text()
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
const hmac = crypto.createHmac('sha256', secret)
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8')
const signature = Buffer.from(request.headers.get('X-Signature') || '', 'utf8')
if (!crypto.timingSafeEqual(digest, signature)) {
throw new Error('Invalid signature.')
}
// Now save the event
const data = JSON.parse(rawBody)
const event = await prisma.webhookEvent.create({
data: {
eventName: data['meta']['event_name'],
body: data
},
})
// Process the event
// This could be done out of the main thread
processEvent(event)
return new Response('Done');
}

View File

@@ -0,0 +1,5 @@
import NextAuth from 'next-auth';
import { authConfig } from '@/lib/auth';
const authHandler = NextAuth(authConfig);
export {authHandler as GET , authHandler as POST};

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
export async function POST(request: Request) {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: true, message: 'Not logged in.' }, { status: 401 })
}
const res = await request.json()
if ( !res.variantId ) {
return NextResponse.json({ error: true, message: 'No variant ID was provided.' }, { status: 400 })
}
// Customise the checkout experience
// All the options: https://docs.lemonsqueezy.com/api/checkouts#create-a-checkout
const attributes = {
'checkout_options': {
'embed': true,
'media': false,
'button_color': '#fde68a'
},
'checkout_data': {
'email': session.user?.email, // Displays in the checkout form
'custom': {
'user_id': session.user?.id // Sent in the background; visible in webhooks and API calls
}
},
'product_options': {
'enabled_variants': [res.variantId], // Only show the selected variant in the checkout
'redirect_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
'receipt_link_url': `${process.env.NEXT_PUBLIC_APP_URL}/billing/`,
'receipt_button_text': 'Go to your account',
'receipt_thank_you_note': 'Thank you for signing up to Lemonstand!'
}
}
try {
const checkout = await ls.createCheckout({
storeId: Number(process.env.LEMON_SQUEEZY_STORE_ID),
variantId: res.variantId,
attributes
})
return NextResponse.json({'error': false, 'url': checkout['data']['attributes']['url']}, {status: 200})
} catch (e) {
return NextResponse.json({'error': true, 'message': e.message}, {status: 400})
}
}

View File

@@ -0,0 +1,42 @@
// import prisma from '@/lib/prisma';
// import { hash } from 'bcrypt';
import { NextResponse } from 'next/server';
export async function POST(req: Request) {
// try {
// const { name, email, password } = (await req.json()) as {
// name: string;
// email: string;
// password: string;
// };
// if (!name || !email || !password) {
// throw { message: 'all fields are required' };
// }
// const hashedPassword = await hash(password, 12);
// const user = await prisma.user.create({
// data: {
// name,
// email: email.toLowerCase(),
// password: hashedPassword,
// },
// });
// return NextResponse.json({
// user: {
// name: user.name,
// email: user.email,
// },
// });
// } catch (error: any) {
return new NextResponse(
JSON.stringify({
status: 'error',
// message: error.message,
}),
{ status: 500 }
);
// }
}

View File

@@ -0,0 +1,121 @@
import { NextResponse } from 'next/server'
import { getPlan } from '@/lib/data'
import LemonSqueezy from '@lemonsqueezy/lemonsqueezy.js'
const ls = new LemonSqueezy(process.env.LEMON_SQUEEZY_API_KEY as string)
export async function GET(_: Request, { params }: { params: { id: string } }) {
/**
* Used by some buttons to get subscription update billing and customer portal URLs
*/
try {
const subscription = await ls.getSubscription({ id: Number(params.id) })
return NextResponse.json({ error: false, subscription: {
update_billing_url: subscription['data']['attributes']['urls']['update_payment_method'],
customer_portal_url: subscription['data']['attributes']['urls']['customer_portal']
} }, { status: 200 })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
}
export async function POST(request: Request, { params }: { params: { id: string } }) {
const res = await request.json()
let subscription
if (res.variantId && res.productId) {
// Update plan
try {
subscription = await ls.updateSubscription({
id: Number(params.id),
productId: res.productId,
variantId: res.variantId,
})
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'resume') {
// Resume
try {
subscription = await ls.resumeSubscription({ id: Number(params.id) })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'cancel') {
// Cancel
try {
subscription = await ls.cancelSubscription({ id: Number(params.id) })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'pause') {
// Pause
try {
subscription = await ls.pauseSubscription({ id: Number(params.id) })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else if (res.action == 'unpause') {
// Unpause
try {
subscription = await ls.unpauseSubscription({ id: Number(params.id) })
} catch (e) {
return NextResponse.json({ error: true, message: e.message }, { status: 400 })
}
} else {
// Missing data in request
return NextResponse.json({ error: true, message: 'Valid data not found.' }, { status: 400 })
}
// Return values needed to refresh state in UI
// DB will be updated in the background with webhooks
// Get price
let resp = await ls.getPrice({ id: subscription['data']['attributes']['first_subscription_item']['price_id'] })
let subItemPrice = resp['data']['attributes']['unit_price']
// Return a filtered subscription object to the UI
const sub = {
product_id: subscription['data']['attributes']['product_id'],
variant_id: subscription['data']['attributes']['variant_id'],
status: subscription['data']['attributes']['status'],
card_brand: subscription['data']['attributes']['card_brand'],
card_last_four: subscription['data']['attributes']['card_last_four'],
trial_ends_at: subscription['data']['attributes']['trial_ends_at'],
renews_at: subscription['data']['attributes']['renews_at'],
ends_at: subscription['data']['attributes']['ends_at'],
resumes_at: subscription['data']['attributes']['resumes_at'],
plan: {},
price: subItemPrice,
}
// Get plan's data
const plan = await getPlan(sub.variant_id)
sub.plan = {
interval: plan?.interval,
name: plan?.variantName
}
return NextResponse.json({ error: false, subscription: sub }, { status: 200 })
}

View File

@@ -2,8 +2,10 @@ import '@/styles/website.scss';
import { creator, description, name, uiUrl } from '@/config/site';
import PageProgress from '@/components/PageProgress';
import { NextAuthProvider } from '@/components/NextAuthProvider';
export const metadata = {
metadataBase: uiUrl,
title: {
default: name,
template: `%s - ${name}`,
@@ -74,7 +76,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body className="body-gradient">
<PageProgress />
{children}
<NextAuthProvider>{children}</NextAuthProvider>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<script src="https://assets.lemonsqueezy.com/lemon.js" defer />
</body>

View File

@@ -1,14 +1,7 @@
import { MetadataRoute } from 'next';
import { allDocs, allGuides, allPages, allPosts } from '@/.contentlayer/generated';
import { uiUrl } from '@/config/site';
const pages = [
'testimonials',
'support',

View File

@@ -42,6 +42,10 @@ const previewHtml = (
}
let content = example;
example = example
.replace(/ href="[^"]+"/g, ' href="javascript:void(0)"')
.replace(/action="[^"]+"/g, 'action="javascript:void(0)"');
if (!fullpage) {
content = `<main class="min-vh-100 ${vertical ? 'p-4' : 'py-4 px-4'}${centered ? ' d-flex justify-content-center align-items-center flex-wrap' : ''}${vcentered ? ' d-flex justify-content-center flex-column' : ''}">
@@ -55,8 +59,6 @@ const previewHtml = (
</main>`;
}
example = example.replace(/a href="[^"]+"/g, 'a href="javascript:void(0)"').replace(/action="[^"]+"/g, 'action="javascript:void(0)"');
return `<html lang="en">
<head>
<title>Example</title>

View File

@@ -33,6 +33,23 @@ const icons = {
>
<path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5" />
</svg>
),
'brand-google': ({ className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={clsx('icon icon-tabler icon-tabler-brand-google', className)}
width={24}
height={24}
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17.788 5.108a9 9 0 1 0 3.212 6.892h-8"></path>
</svg>
),
'brand-sketch': ({ className }) => (
<svg
@@ -861,6 +878,44 @@ const icons = {
<path d="M14 4h6v4h-6z" />
</svg>
),
'logout': ({ className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={clsx('icon icon-tabler icon-tabler-logout', className)}
width={24}
height={24}
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"></path>
<path d="M9 12h12l-3 -3"></path>
<path d="M18 15l3 -3"></path>
</svg>
),
'rocket': ({ className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={clsx('icon icon-tabler icon-tabler-rocket', className)}
width={24}
height={24}
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 13a8 8 0 0 1 7 7a6 6 0 0 0 3 -5a9 9 0 0 0 6 -8a3 3 0 0 0 -3 -3a9 9 0 0 0 -8 6a6 6 0 0 0 -5 3"></path>
<path d="M7 14a6 6 0 0 0 -3 6a6 6 0 0 0 6 -3"></path>
<path d="M15 9m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
</svg>
),
'sun-moon': ({ className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"

292
site/components/Manage.tsx Normal file
View File

@@ -0,0 +1,292 @@
'use client'
import { useState, MouseEvent, Dispatch, SetStateAction } from 'react'
import PlanCards from '@/components/Plan'
import { Plan, Subscription, SubscriptionState } from '@/types'
import clsx from 'clsx'
export const UpdateBillingLink = ({ subscription, elementType }:
{ subscription: SubscriptionState, elementType?: string }
) => {
const [isMutating, setIsMutating] = useState(false)
async function openUpdateModal(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription?.id}`)
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
LemonSqueezy.Url.Open(result.subscription.update_billing_url)
setIsMutating(false)
}
}
if (elementType == 'button') {
return (
<a
href=""
onClick={openUpdateModal}
className={clsx('px-6 py-2 font-bold btn btn-block', {
disabled: isMutating
})}
>
Update your payment method
</a>
)
} else {
return (
<a
href=""
onClick={openUpdateModal}
className={clsx('mb-2', {
disabled: isMutating
})}
>
Update your payment method
</a>
)
}
}
export const CancelLink = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
const [isMutating, setIsMutating] = useState(false)
async function handleCancel(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
if (confirm(`Please confirm you want to cancel your subscription.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'cancel'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
expiryDate: result['subscription']['ends_at'],
})
alert('Your subscription has been cancelled.')
}
}
}
return (
<a
href=""
onClick={handleCancel}
className={clsx('mb-2', {
disabled: isMutating
})}
>
Cancel
</a>
)
}
export const ResumeButton = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
const [isMutating, setIsMutating] = useState(false)
const resumeSubscription = async (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
if (confirm(`Please confirm you want to resume your subscription. You will be charged the regular subscription fee.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'resume'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
renewalDate: result['subscription']['renews_at'],
})
alert('Your subscription is now active again!.')
}
}
}
return (
<a
href=""
onClick={resumeSubscription}
className={clsx('px-6 py-2 font-bold btn btn-block', {
disabled: isMutating
})}
>
Resume your subscription
</a>
)
}
export const PauseLink = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
const [isMutating, setIsMutating] = useState(false)
async function handlePause(e: MouseEvent<HTMLAnchorElement>) {
e.preventDefault()
if (confirm(`Please confirm you want to pause your subscription.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'pause'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
unpauseDate: result['subscription']['resumes_at'],
})
alert('Your subscription has been paused.')
}
}
}
return (
<a
href=""
className={clsx('mb-2', {
disabled: isMutating
})}
onClick={handlePause}
>
Pause payments
</a>
)
}
export const UnpauseButton = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
const [isMutating, setIsMutating] = useState(false)
const unpauseSubscription = async (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
if (confirm(`Please confirm you want to unpause your subscription. Your payments will reactivate on their original schedule.`)) {
setIsMutating(true)
/* Send request */
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
method: 'POST',
body: JSON.stringify({
action: 'unpause'
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
setIsMutating(false)
} else {
setSubscription({
...subscription,
status: result['subscription']['status'],
renewalDate: result['subscription']['renews_at'],
})
alert('Your subscription is now active again!')
}
}
}
return (
<a href=""
onClick={unpauseSubscription}
className={clsx('px-6 py-2 font-bold btn btn-block', {
disabled: isMutating
})}
>
Unpause your subscription
</a>
)
}
export const PlansComponent = ({ plans, sub }:
{ plans: Plan[], sub: Subscription }
) => {
const [subscription, setSubscription] = useState<SubscriptionState>(() => {
if (sub) {
return {
id: sub.lemonSqueezyId,
planName: sub.plan?.variantName,
planInterval: sub.plan?.interval,
productId: sub.plan?.productId,
variantId: sub.plan?.variantId,
status: sub.status,
renewalDate: sub.renewsAt,
trialEndDate: sub.trialEndsAt,
expiryDate: sub.endsAt,
}
} else {
return undefined
}
})
return (
<PlanCards plans={plans} subscription={subscription} setSubscription={setSubscription} />
)
}

View File

@@ -1,7 +1,7 @@
import Link from '@/components/Link';
import clsx from 'clsx';
function NavLink({ href, active, children, ...props }) {
function NavLink({ href, active = false, children, ...props }) {
if (active) {
props.className = clsx(props.className, 'active');
}

View File

@@ -0,0 +1,11 @@
'use client';
import { SessionProvider } from 'next-auth/react';
type Props = {
children?: React.ReactNode;
};
export const NextAuthProvider = ({ children }: Props) => {
return <SessionProvider>{children}</SessionProvider>;
};

122
site/components/Plan.tsx Normal file
View File

@@ -0,0 +1,122 @@
'use client'
import { useState, useEffect, Dispatch, SetStateAction } from 'react'
import PlanButton from '@/components/PlanButton'
import { isPlanFeatured } from '@/helpers';
import clsx from 'clsx';
import Icon from '@/components/Icon';
import type { Plan, SubscriptionState } from '@/types';
function formatPrice(price: number) {
const priceString = price.toString()
const dollars = priceString.substring(0, priceString.length-2)
const cents = priceString.substring(priceString.length-2)
if (cents === '00') return dollars
return `${dollars}.${cents}`
}
const formatDescription = (description?: string) => {
if (!description) return;
const pricingFeatures = description
.replaceAll('<p>','')
.replaceAll('</p>','')
.split('<br>')
return <ul className="pricing-features">
{
pricingFeatures.map((pricingFeature) => (
<li key={pricingFeature}>
<Icon name="check" className="text-green mr-2" />
{pricingFeature}
</li>
))
}
</ul>
}
const IntervalSwitcher = ({ intervalValue, changeInterval }:
{ intervalValue: string, changeInterval: Dispatch<SetStateAction<string>> }
) => {
return (
<div className="text-center mb-5">
<span className="mr-2">Monthly</span>
<label className="form-switch">
<input
className="form-switch-input"
type="checkbox"
checked={intervalValue == 'year'}
onChange={(e) => changeInterval(e.target.checked ? 'year' : 'month')}
/>
<span className="slider"/>
</label>
<span className="ml-2">Yearly</span>
</div>
)
}
const PlanCard = ({ plan, subscription, intervalValue, setSubscription }:
{ plan: Plan, subscription: SubscriptionState, intervalValue: string, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<div
className={clsx({
'featured': isPlanFeatured(plan),
'pricing-card': plan.interval === intervalValue,
'visually-hidden': plan.interval !== intervalValue,
})}
>
{
isPlanFeatured(plan) &&
<div className="pricing-label">
<div className="label label-primary label-sm">Popular</div>
</div>
}
<h4 className="pricing-title">{plan.variantName}</h4>
<div className="pricing-price">
<span className="pricing-price-currency">$</span>
{ formatPrice(plan.price) }
<div className="pricing-price-description">
<div>per {plan.interval}</div>
</div>
</div>
{formatDescription(plan.description || '')}
<PlanButton plan={plan} subscription={subscription} setSubscription={setSubscription} />
</div>
)
}
const PlanCards = ({ plans, subscription, setSubscription }:
{ plans: Plan[], subscription?: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
const [intervalValue, setIntervalValue] = useState('month')
// Make sure Lemon.js is loaded
useEffect(() => {
window.createLemonSqueezy()
}, [])
return (
<>
<IntervalSwitcher intervalValue={intervalValue} changeInterval={setIntervalValue} />
<div className="pricing">
{plans.map(plan => (
<PlanCard plan={plan} subscription={subscription} intervalValue={intervalValue} key={plan.variantId} setSubscription={setSubscription} />
))}
</div>
<p className="h-subheader mt-8 text-center">
Payments are processed securely by Lemon Squeezy.
</p>
</>
)
}
export default PlanCards

View File

@@ -0,0 +1,124 @@
'use client'
import { Dispatch, SetStateAction, useState, MouseEvent } from 'react'
import clsx from 'clsx';
import { isPlanFeatured } from '@/helpers';
import { Plan, SubscriptionState } from '@/types';
const PlanButton = ({ plan, subscription, setSubscription }:
{ plan: Plan, subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
const [isMutating, setIsMutating] = useState(false)
const createCheckout = async (e: MouseEvent<HTMLAnchorElement>, variantId: number) => {
e.preventDefault()
if (isMutating) return
setIsMutating(true)
// Create a checkout
const res = await fetch('/api/checkouts', {
method: 'POST',
body: JSON.stringify({
variantId: variantId
})
})
const checkout = await res.json()
if (checkout.error) {
alert(checkout.message)
} else {
LemonSqueezy.Url.Open(checkout['url'])
}
setIsMutating(false)
}
const changePlan = async (e: MouseEvent<HTMLAnchorElement>, subscription: SubscriptionState, plan: Plan) => {
e.preventDefault()
if (isMutating) return
if (confirm(`Please confirm you want to change to the ${plan.variantName} ${plan.interval}ly plan. \
For upgrades you will be charged a prorated amount.`)) {
setIsMutating(true)
// Send request
const res = await fetch(`/api/subscriptions/${subscription?.id}`, {
method: 'POST',
body: JSON.stringify({
variantId: plan.variantId,
productId: plan.productId
})
})
const result = await res.json()
if (result.error) {
alert(result.message)
} else {
setSubscription({
...subscription,
productId: result['subscription']['product_id'],
variantId: result['subscription']['variant_id'],
planName: result['subscription']['plan']['name'],
planInterval: result['subscription']['plan']['interval'],
status: result['subscription']['status'],
renewalDate: result['subscription']['renews_at'],
price: result['subscription']['price']
})
alert('Your subscription plan has changed!')
// Webhooks will update the DB in the background
}
setIsMutating(false)
}
}
return (
<>
{(!subscription || subscription.status == 'expired') ? (
// Show a "Sign up" button to customers with no subscription
<div className="pricing-btn">
<a
href="#"
onClick={(e) => createCheckout(e, plan.variantId)}
className={clsx('btn btn-block', {
'btn-primary': isPlanFeatured(plan),
disabled: isMutating
})}
>
Sign up
</a>
</div>
) : (
<>
{subscription?.variantId == plan.variantId ? (
<div className="pricing-btn">
<span className="font-bold select-none">Your current plan</span>
</div>
) : (
<div className="pricing-btn">
<a
href="#"
onClick={(e) => changePlan(e, subscription, plan)}
className={clsx('btn btn-block', {
'btn-primary': isPlanFeatured(plan),
disabled: isMutating
})}
>
Change to this plan
</a>
</div>
)}
</>
)}
</>
)
}
export default PlanButton

154
site/components/Signin.tsx Normal file
View File

@@ -0,0 +1,154 @@
'use client';
import Link from '@/components/Link';
import { useSearchParams } from 'next/navigation';
import Icon from '@/components/Icon';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { getNextAuthErrorMessage } from '@/helpers/index';
export default function Signin() {
// const router = useRouter();
const searchParams = useSearchParams();
// const [loading, setLoading] = useState(false);
// const [formValues, setFormValues] = useState({
// email: '',
// password: '',
// });
const nextAuthError = searchParams.get('error');
const [error] = useState(nextAuthError ? getNextAuthErrorMessage(nextAuthError) : '');
const callbackUrl = searchParams.get('callbackUrl') || '/';
// const onSubmit = async (e: React.FormEvent) => {
// e.preventDefault();
// try {
// setLoading(true);
// setFormValues({ email: '', password: '' });
// const res = await signIn('credentials', {
// redirect: false,
// email: formValues.email,
// password: formValues.password,
// callbackUrl,
// });
// setLoading(false);
// if (!res?.error) {
// router.push(callbackUrl);
// } else {
// setError('Invalid email or password');
// }
// } catch (error: any) {
// setLoading(false);
// setError(error);
// }
// };
// const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
// const { name, value } = event.target;
// setFormValues({ ...formValues, [name]: value });
// };
return (
<>
<div className="text-center mb-4">
<Link href="/" className="mx-auto d-inline-block logo" aria-label="Tabler" />
</div>
<div className="flex-column card card-md">
{/* <div className="card-body">
<h2 className="h2 text-center mb-4">Login to your account</h2>
{
error &&
<p className="text-center" style={{color: 'red'}}>{error}</p>
}
<form onSubmit={onSubmit}>
<div className="mb-3">
<label className="form-label">Email address</label>
<input
required
type="email"
name="email"
value={formValues.email}
onChange={handleChange}
className="form-control"
placeholder="your@email.com"
/>
</div>
<div className="mb-2">
<label className="form-label">
Password
</label>
<div className="input-group input-group-flat">
<input
required
type="password"
name="password"
value={formValues.password}
onChange={handleChange}
className="form-control"
placeholder="Your password"
/>
</div>
</div>
<div className="form-footer">
<button
type="submit"
className="btn btn-primary w-100"
disabled={loading}
>
{loading ? 'loading...' : 'Sign In'}
</button>
</div>
</form>
</div>*/}
<div className="card-body">
<h2 className="h2 text-center mb-4">Login to your account</h2>
{
error &&
<p className="text-center" style={{color: 'red'}}>{error}</p>
}
<div className="mb-2">
<label className="form-label text-center">
Using email and password
</label>
<div className="form-footer">
<button
className="btn btn-primary w-100"
onClick={() => signIn('auth0', { callbackUrl })}
>
Sign In
</button>
</div>
</div>
</div>
<div className="hr-text">or</div>
<div className="card-body">
<div className="row">
<div className="col">
<a onClick={() => signIn('github', { callbackUrl })} className="btn w-100">
<Icon name="brand-github"/>
Login with Github
</a>
</div>
<div className="col">
<a onClick={() => signIn('google', { callbackUrl })} className="btn w-100">
<Icon name="brand-google"/>
Login with Google
</a>
</div>
</div>
</div>
</div>
{/* <div className="text-center text-secondary mt-3">
Don't have account yet?
<a className="ml-2" onClick={() => router.push('/signup')}>
Sign up
</a>
</div> */}
</>
);
}

113
site/components/Signup.tsx Normal file
View File

@@ -0,0 +1,113 @@
'use client';
// import Link from '@/components/Link';
// import { useRouter } from 'next/navigation';
// import { ChangeEvent, useState } from 'react';
// import { signIn } from 'next-auth/react';
export default function Signup() {
// const router = useRouter();
// const [loading, setLoading] = useState(false);
// const [formValues, setFormValues] = useState({
// name: '',
// email: '',
// password: '',
// });
// const [error, setError] = useState('');
// const onSubmit = async (e: React.FormEvent) => {
// e.preventDefault();
// setLoading(true);
// setFormValues({ name: '', email: '', password: '' });
// try {
// const res = await fetch('/api/register', {
// method: 'POST',
// body: JSON.stringify(formValues),
// headers: {
// 'Content-Type': 'application/json',
// },
// });
// setLoading(false);
// if (!res.ok) {
// setError((await res.json()).message);
// return;
// }
// signIn(undefined, { callbackUrl: '/' });
// } catch (error: any) {
// setLoading(false);
// setError(error);
// }
// };
// const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
// const { name, value } = event.target;
// setFormValues({ ...formValues, [name]: value });
// };
return (
<>
{/* <div className="text-center mb-4">
<Link href="/" className="mx-auto d-inline-block logo" aria-label="Tabler" />
</div>
<form onSubmit={onSubmit} className="flex-column card card-md">
<div className="card-body">
<h2 className="card-title text-center mb-4">Create new account</h2>
{
error &&
<p className="text-center" style={{color: 'red'}}>{error}</p>
}
<div className="mb-3">
<label className="form-label">Name</label>
<input
required
type="name"
name="name"
value={formValues.name}
onChange={handleChange}
className="form-control"
placeholder="Name"
/>
</div>
<div className="mb-3">
<label className="form-label">Email address</label>
<input
required
type="email"
name="email"
value={formValues.email}
onChange={handleChange}
className="form-control"
placeholder="Enter email"
/>
</div>
<div className="mb-3">
<label className="form-label">Password</label>
<div className="input-group input-group-flat">
<input
type="password"
name="password"
value={formValues.password}
onChange={handleChange}
className="form-control"
placeholder="Password"
/>
</div>
</div>
<div className="form-footer">
<button className="btn btn-primary w-100" disabled={loading}>
{loading ? 'loading...' : 'Create new account'}
</button>
</div>
</div>
</form>
<div className="text-center text-secondary mt-3">
Already have account?
<a className="ml-2" onClick={() => router.push('/api/auth/signin')}>
Sign in</a>
</div> */}
</>
);
}

View File

@@ -0,0 +1,238 @@
'use client'
import { useState, useEffect, SetStateAction, Dispatch } from 'react'
import Link from 'next/link'
import PlanCards from '@/components/Plan'
import {
UpdateBillingLink,
CancelLink,
ResumeButton,
PauseLink,
UnpauseButton
} from '@/components/Manage'
import { Plan, Subscription, SubscriptionState } from '@/types'
export const SubscriptionComponent = ({ sub, plans }:
{ sub: Subscription | null, plans: Plan[] }
) => {
// Make sure Lemon.js is loaded
useEffect(() => {
window.createLemonSqueezy()
}, [])
const [subscription, setSubscription] = useState<SubscriptionState>(() => {
if (sub) {
return {
id: sub.lemonSqueezyId,
planName: sub.plan?.variantName,
planInterval: sub.plan?.interval,
productId: sub.plan?.productId,
variantId: sub.plan?.variantId,
status: sub.status,
renewalDate: sub.renewsAt,
trialEndDate: sub.trialEndsAt,
expiryDate: sub.endsAt,
unpauseDate: sub.resumesAt,
price: sub.price / 100,
}
} else {
return undefined
}
})
if (sub) {
switch(subscription?.status) {
case 'active':
return <ActiveSubscription subscription={subscription} setSubscription={setSubscription} />
case 'on_trial':
return <TrialSubscription subscription={subscription} setSubscription={setSubscription} />
case 'past_due':
return <PastDueSubscription subscription={subscription} setSubscription={setSubscription} />
case 'cancelled':
return <CancelledSubscription subscription={subscription} setSubscription={setSubscription} />
case 'paused':
return <PausedSubscription subscription={subscription} setSubscription={setSubscription} />
case 'unpaid':
return <UnpaidSubscription subscription={subscription} setSubscription={setSubscription} />
case 'expired':
return <ExpiredSubscription subscription={subscription} plans={plans} setSubscription={setSubscription} />
}
}
return (
<>
<p className="text-center">Please sign up to a paid plan.</p>
<PlanCards plans={plans} setSubscription={setSubscription} />
</>
)
}
export const ActiveSubscription = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<>
<p className="mb-2">
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
</p>
<p className="mb-2">Your next renewal will be on {formatDate(subscription?.renewalDate)}.</p>
<hr className="my-8" />
<p className="mb-4">
<Link href="/billing/change-plan" className="px-6 py-2 font-bold">
Change plan &rarr;
</Link>
</p>
<p><UpdateBillingLink subscription={subscription} /></p>
<p><PauseLink subscription={subscription} setSubscription={setSubscription} /></p>
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
</>
)
}
export const CancelledSubscription = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<>
<p className="mb-2">
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
</p>
<p className="mb-8">Your subscription has been cancelled and <b>will end on {formatDate(subscription?.expiryDate)}</b>. After this date you will no longer have access to the app.</p>
<p><ResumeButton subscription={subscription} setSubscription={setSubscription} /></p>
</>
)
}
export const PausedSubscription = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<>
<p className="mb-2">
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
</p>
{subscription?.unpauseDate ? (
<p className="mb-8">Your subscription payments are currently paused. Your subscription will automatically resume on {formatDate(subscription?.unpauseDate)}.</p>
) : (
<p className="mb-8">Your subscription payments are currently paused.</p>
)}
<p><UnpauseButton subscription={subscription} setSubscription={setSubscription} /></p>
</>
)
}
export const TrialSubscription = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<>
<p className="mb-2">
You are currently on a free trial of the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
</p>
<p className="mb-6">Your trial ends on {formatDate(subscription?.trialEndDate)}. You can cancel your subscription before this date and you won&apos;t be charged.</p>
<hr className="my-8" />
<p className="mb-4">
<Link href="/billing/change-plan" className="px-6 py-2 font-bold">
Change plan &rarr;
</Link>
</p>
<p><UpdateBillingLink subscription={subscription} /></p>
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
</>
)
}
export const PastDueSubscription = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<>
<div className="my-8 p-4">
Your latest payment failed. We will re-try this payment up to four times, after which your subscription will be cancelled.<br />
If you need to update your billing details, you can do so below.
</div>
<p className="mb-2">
You are currently on the <b>{subscription?.planName} {subscription?.planInterval}ly</b> plan, paying ${subscription?.price}/{subscription?.planInterval}.
</p>
<p className="mb-2">We will attempt a payment on {formatDate(subscription?.renewalDate)}.</p>
<hr className="my-8" />
<p><UpdateBillingLink subscription={subscription} /></p>
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
</>
)
}
export const UnpaidSubscription = ({ subscription, setSubscription }:
{ subscription: SubscriptionState, setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
/*
Unpaid subscriptions have had four failed recovery payments.
If you have dunning enabled in your store settings, customers will be sent emails trying to reactivate their subscription.
If you don't have dunning enabled the subscription will remain "unpaid".
*/
return (
<>
<p className="mb-2">We haven&apos;t been able to make a successful payment and your subscription is currently marked as unpaid.</p>
<p className="mb-6">Please update your billing information to regain access.</p>
<p><UpdateBillingLink subscription={subscription} elementType="button" /></p>
<hr className="my-8" />
<p><CancelLink subscription={subscription} setSubscription={setSubscription} /></p>
</>
)
}
export const ExpiredSubscription = ({ subscription, plans, setSubscription }:
{ subscription: SubscriptionState, plans: Plan[], setSubscription: Dispatch<SetStateAction<SubscriptionState>> }
) => {
return (
<>
<p className="mb-2">Your subscription expired on {formatDate(subscription?.expiryDate)}.</p>
<p className="mb-2">Please create a new subscription to regain access.</p>
<hr className="my-8" />
<PlanCards subscription={subscription} plans={plans} setSubscription={setSubscription} />
</>
)
}
function formatDate(date?: Date | null) {
if (!date) return ''
return new Date(date).toLocaleString('en-US', {
month: 'short',
day: "2-digit",
year: 'numeric'
})
}

View File

@@ -0,0 +1,32 @@
'use client';
import { useRouter } from 'next/navigation';
import Icon from '@/components/Icon';
export default function EntryHeader() {
const router = useRouter();
return (
<header className="header">
<div className="container">
<nav className="row items-center">
<div className="col-auto">
<div className="d-block">
<div className="navbar">
<div className="navbar-item">
<a
className="btn"
onClick={() => router.push('/')}
>
<Icon name="chevron-left"/>
Back
</a>
</div>
</div>
</div>
</div>
</nav>
</div>
</header>
);
}

View File

@@ -1,16 +1,27 @@
'use client';
import { Fragment, MutableRefObject, PropsWithChildren, RefObject, useCallback, useEffect, useRef, useState } from 'react';
import {
Fragment,
useEffect,
useState,
} from 'react';
import { Dialog, Popover } from '@headlessui/react';
import clsx from 'clsx';
import { banner, blogEnabled, componentsRounded, iconsCountRounded, sponsorsUrl, uiGithubUrl } from '@/config/site';
import { banner,
blogEnabled,
iconsCountRounded,
sponsorsUrl,
uiGithubUrl,
} from '@/config/site';
import Icon from '@/components/Icon';
import GoToTop from '@/components/layout/GoToTop';
import Link from '@/components/Link';
import NavLink from '@/components/NavLink';
import Shape from '@/components/Shape';
import { usePathname } from 'next/navigation';
import { signOut, useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
const NavDropdown = ({ title, children, active, footer = false }) => {
return (
@@ -152,7 +163,7 @@ const NavbarLink = (link, menu) => {
return (
// router.pathname.replace(/^\//, '').startsWith(link.menu)
<NavLink href={link.href} className="navbar-link" active={false}>
<NavLink href={link.href} className="navbar-link">
{link.title}
</NavLink>
);
@@ -190,8 +201,97 @@ const SidebarLink = (link, menu, onClick) => {
);
};
const Navbar = ({ menu, opened, onClick, ...props }: { menu?: string; opened?: boolean; onClick?: (event: React.MouseEvent) => void; className?: string }) => {
return <div className={clsx('navbar', opened && 'opened', props.className)}>{menuLinks.map((link) => (<Fragment key={link.menu}>{NavbarLink(link, menu)}</Fragment>))}</div>;
const NavigationAuth = () => {
const { data: session, status } = useSession();
const router = useRouter();
const image = session?.user?.image;
const name = session?.user?.name;
const email = session?.user?.email;
const signIn = () => {
if (status === 'loading') return;
router.push('/api/auth/signin');
};
return <div className="navbar-item d-flex items-center">
{
!session &&
<a onClick={() => signIn()} className={clsx('btn', { disabled: status === 'loading'})}>
Log in
</a>
}
{
session &&
<Popover className="navbar-dropdown">
{({ open }) => (
<>
<Popover.Button className={clsx('navbar-link d-flex items-center lh-1 text-reset p-0')}>
{
image
? <span
className="avatar avatar"
style={{
backgroundImage: `url(${image})`,
}}
/>
: <span className="avatar avatar text-center">
{name ? name.toUpperCase().substring(0, 1) : 'T'}
</span>
}
<div className="pl-2">
<small className="d-block">{name}</small>
{
email &&
<small className="mt-1 small text-muted">{session.user?.email}</small>
}
</div>
</Popover.Button>
<Popover.Panel className="navbar-dropdown-menu">
<div className="navbar-dropdown-menu-content">
<div onClick={() => router.push('billing')} className="navbar-dropdown-menu-link">
<div className="row items-center g-3">
<div className="col-auto">
<Shape icon='rocket'/>
</div>
<div className="col">
<h5>Billing</h5>
</div>
</div>
</div>
<div onClick={() => signOut()} className="navbar-dropdown-menu-link">
<div className="row items-center g-3">
<div className="col-auto">
<Shape icon='logout'/>
</div>
<div className="col">
<h5>Log out</h5>
</div>
</div>
</div>
</div>
</Popover.Panel>
</>
)}
</Popover>
}
</div>;
};
const Navbar = ({
menu,
opened,
onClick,
...props
}: {
menu?: string;
opened?: boolean;
onClick?: (event: React.MouseEvent) => void;
className?: string
}) => {
return <div className={clsx('navbar', opened && 'opened', props.className)}>
{menuLinks.map((link) => (<Fragment key={link.menu}>{NavbarLink(link, menu)}</Fragment>))}
<NavigationAuth/>
</div>;
};
const Banner = () => {

View File

@@ -1,40 +1,81 @@
import { Plan } from "@/types";
export const groupBy = function (xs, key) {
return xs.reduce(function (rv, x) {
;(rv[x[key]] = rv[x[key]] || []).push(x)
return rv
}, {})
}
;(rv[x[key]] = rv[x[key]] || []).push(x);
return rv;
}, {});
};
export const sortByKeys = function (xs) {
return Object.keys(xs)
.sort()
.reduce((obj, key) => {
obj[key] = xs[key]
return obj
}, {})
}
obj[key] = xs[key];
return obj;
}, {});
};
export const toPascalCase = function (text: string) {
return text
.replace(new RegExp(/[-_]+/, "g"), " ")
.replace(new RegExp(/[^\w\s]/, "g"), "")
.replace(new RegExp(/[-_]+/, 'g'), ' ')
.replace(new RegExp(/[^\w\s]/, 'g'), '')
.replace(
new RegExp(/\s+(.)(\w+)/, "g"),
new RegExp(/\s+(.)(\w+)/, 'g'),
($1, $2, $3) => `${$2.toUpperCase() + $3.toLowerCase()}`
)
.replace(new RegExp(/\s/, "g"), "")
.replace(new RegExp(/\w/), (s) => s.toUpperCase())
}
.replace(new RegExp(/\s/, 'g'), '')
.replace(new RegExp(/\w/), (s) => s.toUpperCase());
};
export const baseUrl = {
development: "http://localhost:3000",
production: "https://tabler-icons.io",
}[process.env.NODE_ENV]
development: 'http://localhost:3000',
production: 'https://tabler-icons.io',
}[process.env.NODE_ENV];
export const getCurrentBrand = (hostname: string) => {
if (hostname && hostname.match(/tabler-icons/)) {
return "tabler-icons"
return 'tabler-icons';
}
return "tabler-ui"
}
return 'tabler-ui';
};
export const getNextAuthErrorMessage = (error: string): string => {
// Nextauth errors
// https://next-auth.js.org/configuration/pages#sign-in-page
// OAuthSignin: Error in constructing an authorization URL (1, 2, 3),
// OAuthCallback: Error in handling the response (1, 2, 3) from an OAuth provider.
// OAuthCreateAccount: Could not create OAuth provider user in the database.
// EmailCreateAccount: Could not create email provider user in the database.
// Callback: Error in the OAuth callback handler route
// OAuthAccountNotLinked: If the email on the account is already linked, but not with this OAuth account
// EmailSignin: Sending the e-mail with the verification token failed
// CredentialsSignin: The authorize callback returned null in the Credentials provider. We don't recommend providing information about which part of the credentials were wrong, as it might be abused by malicious hackers.
// SessionRequired: The content of this page requires you to be signed in at all times. See useSession for configuration.
// Default: Catch all, will apply, if none of the above matched
switch (error) {
case 'OAuthSignin':
case 'OAuthCallback':
case 'OAuthCreateAccount':
case 'EmailCreateAccount':
case 'Callback':
return 'Try signing in with a different account.';
case 'OAuthAccountNotLinked':
return 'To confirm your identity, sign in with the same account you used originally.';
case 'EmailSignin':
return 'The e-mail could not be sent.';
case 'CredentialsSignin':
return 'Sign in failed. Check the details you provided are correct.';
case 'SessionRequired':
return 'Please sign in to access this page.';
case 'Default':
default:
return 'Unable to sign in.';
}
};
export const isPlanFeatured = (plan: Plan) => {
return plan.variantName === 'Advanced'
}

112
site/lib/auth.ts Normal file
View File

@@ -0,0 +1,112 @@
import prisma from '@/lib/prisma'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import GitHubProvider from 'next-auth/providers/github'
import GoogleProvider from 'next-auth/providers/google'
import { NextAuthOptions } from 'next-auth'
import Auth0Provider from 'next-auth/providers/auth0'
import { getServerSession } from 'next-auth/next'
import type { ExtendedSession } from '@/types'
export const authConfig: NextAuthOptions = {
providers: [
// CredentialsProvider({
// name: 'Login to your account',
// credentials: {
// email: {
// label: 'Email address',
// type: 'text',
// placeholder: 'your@email.com"'
// },
// password: {
// label: 'Password',
// type: 'password',
// placeholder: 'Your password'
// },
// },
// async authorize(credentials) {
// if (!credentials || !credentials.email || !credentials.password) {
// return null;
// }
// const user = await prisma.user.findUnique({
// where: { email: credentials.email }
// });
// if (!user) {
// return null;
// }
// const isPasswordValid = await compare(
// credentials.password,
// user.password
// );
// if (!isPasswordValid) {
// return null;
// }
// const { password, ...userWithoutPassword } = user;
// return userWithoutPassword as User;
// }
// }),
GitHubProvider({
clientId: process.env.GITHUB_ID as string,
clientSecret: process.env.GITHUB_SECRET as string,
}),
GoogleProvider({
clientId: process.env.GOOGLE_ID as string,
clientSecret: process.env.GOOGLE_SECRET as string,
}),
Auth0Provider({
clientId: process.env.AUTH0_CLIENT_ID as string,
clientSecret: process.env.AUTH0_CLIENT_SECRET as string,
issuer: process.env.AUTH0_ISSUER as string
}),
],
pages: {
signIn: '/signin'
},
adapter: PrismaAdapter(prisma),
secret: process.env.NEXTAUTH_SECRET,
session: {
strategy: 'jwt',
},
callbacks: {
// Add user ID to session from token
session: async ({ session, token }) => {
return {
...session,
user: {
...session.user,
id: token.sub,
},
}
}
}
// callbacks: {
// session: ({ session, token }) => {
// return {
// ...session,
// user: {
// ...session.user,
// id: token.id,
// },
// };
// },
// jwt: ({ token, user }) => {
// if (user) {
// const u = user as unknown as any;
// return {
// ...token,
// id: u.id,
// };
// }
// return token;
// },
// },
};
export function getSession(): Promise<ExtendedSession> {
return getServerSession(authConfig) as Promise<ExtendedSession>
}

50
site/lib/data.ts Normal file
View File

@@ -0,0 +1,50 @@
import prisma from '@/lib/prisma'
export async function getPlans() {
// Gets all active plans
return await prisma.plan.findMany({
where: {
NOT: [
{ status: 'draft' },
{ status: 'pending' }
]
},
include: {
subscriptions: true
}
})
}
export async function getPlan(variantId: number) {
// Gets single active plan by ID
return await prisma.plan.findFirst({
where: {
variantId: variantId,
NOT: [
{ status: 'draft' },
{ status: 'pending' }
]
},
include: {
subscriptions: true
}
})
}
export async function getSubscription(userId?: string) {
// Gets the most recent subscription
return await prisma.subscription.findFirst({
where: {
userId: userId
},
include: {
plan: true,
user: true
},
orderBy: {
lemonSqueezyId: 'desc'
}
})
}

14
site/lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
let prisma: PrismaClient;
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient();
} else {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
}
export default prisma;

3
site/middleware.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default } from 'next-auth/middleware';
export const config = { matcher: ['/billing'] };

View File

@@ -10,7 +10,8 @@ const nextConfig = {
domains: ["avatars.githubusercontent.com"],
},
experimental: {
appDir: true
appDir: true,
serverActions: true,
},
async redirects() {
return JSON.parse(fs.readFileSync('./redirects.json'))

View File

@@ -2,6 +2,7 @@
"private": true,
"sourceType": "module",
"scripts": {
"postinstall": "pnpm prisma generate",
"dev": "concurrently \"contentlayer dev\" \"next dev --port 3010\"",
"build": "contentlayer build && next build",
"start": "next start",
@@ -23,10 +24,13 @@
"dependencies": {
"@glidejs/glide": "^3.6.0",
"@headlessui/react": "^1.7.16",
"@lemonsqueezy/lemonsqueezy.js": "^1.2.2",
"@mdx-js/loader": "^2.3.0",
"@mdx-js/react": "2.3.0",
"@next/env": "^13.4.12",
"@next/mdx": "^13.4.12",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/env": "^13.5.3",
"@next/mdx": "^13.5.3",
"@prisma/client": "5.1.1",
"@sindresorhus/slugify": "^2.2.1",
"@svgr/webpack": "^8.0.1",
"@tabler/icons": "^2.30.0",
@@ -45,14 +49,14 @@
"acorn": "^8.10.0",
"acorn-jsx": "^5.3.2",
"aos": "^2.3.4",
"autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.16",
"clsx": "^2.0.0",
"concurrently": "^8.2.0",
"contentlayer": "^0.3.4",
"date-fns": "^2.30.0",
"dlv": "^1.1.3",
"eslint": "8.x",
"eslint-config-next": "13.4.12",
"eslint-config-next": "13.5.3",
"file-loader": "^6.2.0",
"front-matter": "^4.0.2",
"fs-extra": "^11.1.1",
@@ -62,7 +66,7 @@
"image-size": "^1.0.2",
"js-beautify": "^1.14.9",
"markdown-wasm": "^1.2.0",
"mdast-util-frontmatter": "^2.0.0",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-mdx-jsx": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
@@ -73,14 +77,14 @@
"mdx-annotations": "^0.1.3",
"minimatch": "^9.0.3",
"modern-async": "^1.1.3",
"next": "^13.4.12",
"next-auth": "^4.22.3",
"next": "^13.5.3",
"next-auth": "^4.23.2",
"next-contentlayer": "^0.3.4",
"next-mdx-remote": "^4.4.1",
"next-sitemap": "^4.1.8",
"nextjs-toploader": "^1.4.2",
"opentype.js": "^1.3.4",
"postcss": "^8.4.27",
"postcss": "^8.4.30",
"prettier": "3.0.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
@@ -115,7 +119,10 @@
"yaml": "^2.3.1"
},
"devDependencies": {
"@babel/plugin-transform-private-methods": "^7.22.5",
"@t3-oss/env-nextjs": "^0.6.0",
"prisma": "^5.1.1",
"yaml": "^2.3.2",
"zod": "^3.21.4"
}
}

996
site/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"provider_account_id" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
"refresh_token_expires_in" INTEGER,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"session_token" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"email_verified" TIMESTAMP(3),
"image" TEXT,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verificationtokens" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" SERIAL NOT NULL,
"lemon_squeezy_id" INTEGER NOT NULL,
"order_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"status" TEXT NOT NULL,
"renews_at" TIMESTAMP(3),
"ends_at" TIMESTAMP(3),
"trial_ends_at" TIMESTAMP(3),
"resumes_at" TIMESTAMP(3),
"price" INTEGER NOT NULL,
"plan_id" INTEGER NOT NULL,
"user_id" TEXT NOT NULL,
"is_usage_based" BOOLEAN NOT NULL DEFAULT false,
"subscription_item_id" INTEGER,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Plan" (
"id" SERIAL NOT NULL,
"product_id" INTEGER NOT NULL,
"variant_id" INTEGER NOT NULL,
"name" TEXT,
"description" TEXT,
"variant_name" TEXT NOT NULL,
"sort" INTEGER NOT NULL,
"status" TEXT NOT NULL,
"price" INTEGER NOT NULL,
"interval" TEXT NOT NULL,
"interval_count" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookEvent" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"event_name" TEXT NOT NULL,
"processed" BOOLEAN NOT NULL DEFAULT false,
"body" JSONB NOT NULL,
"processing_error" TEXT,
CONSTRAINT "WebhookEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_lemon_squeezy_id_key" ON "Subscription"("lemon_squeezy_id");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_order_id_key" ON "Subscription"("order_id");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_subscription_item_id_key" ON "Subscription"("subscription_item_id");
-- CreateIndex
CREATE INDEX "Subscription_plan_id_lemon_squeezy_id_idx" ON "Subscription"("plan_id", "lemon_squeezy_id");
-- CreateIndex
CREATE UNIQUE INDEX "Plan_variant_id_key" ON "Plan"("variant_id");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

107
site/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,107 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
refresh_token_expires_in Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
sessions Session[]
subscription Subscription[]
@@map("users")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}
model Subscription {
id Int @id @default(autoincrement())
lemonSqueezyId Int @unique @map("lemon_squeezy_id")
orderId Int @unique @map("order_id")
name String
email String
status String
renewsAt DateTime? @map("renews_at")
endsAt DateTime? @map("ends_at")
trialEndsAt DateTime? @map("trial_ends_at")
resumesAt DateTime? @map("resumes_at")
price Int
plan Plan @relation(fields: [planId], references: [id])
planId Int @map("plan_id")
user User @relation(fields: [userId], references: [id])
userId String @map("user_id")
isUsageBased Boolean @default(false) @map("is_usage_based")
subscriptionItemId Int? @unique @map("subscription_item_id")
@@index([planId, lemonSqueezyId])
}
model Plan {
id Int @id @default(autoincrement())
productId Int @map("product_id")
variantId Int @unique @map("variant_id")
name String? // Need to get from Product
description String?
variantName String @map("variant_name")
sort Int
status String
price Int
interval String
intervalCount Int @default(1) @map("interval_count")
subscriptions Subscription[]
}
model WebhookEvent {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
eventName String @map("event_name")
processed Boolean @default(false)
body Json
processingError String? @map("processing_error")
}

View File

@@ -46,6 +46,7 @@
.icon {
width: divide(20, 16) * 1em;
min-width: divide(20, 16) * 1em;
height: divide(20, 16) * 1em;
vertical-align: bottom;
margin-right: $gap-2;

View File

@@ -21,6 +21,10 @@ Cards
.card-body {
padding: $gap-4;
flex: 1;
.card-md > & {
padding: 2.5rem;
}
}
.card-title {

View File

@@ -167,6 +167,7 @@ $grid-breakpoints: (
$container-max-width: px2rem(1280px);
$container-narrow-max-width: px2rem(990px);
$container-slim-max-width: px2rem(660px);
$container-tight-max-width: px2rem(500px);
$zindex-modal: 100;
$zindex-gototop: 90;
@@ -177,6 +178,7 @@ $grid-columns: 12;
$header-height: 5rem;
$aside-width: 20rem;
$hr-margin-y: 2rem;
$form-focus-color: $color-primary;
$form-check-size: 1rem;

View File

@@ -92,5 +92,8 @@
}
}
div.example {
iframe.example-frame {
min-height: 7rem !important;
}
}

View File

@@ -72,6 +72,11 @@ $form-range-thumb-transition: background-color .3s ease-in-out, bor
}
}
.form-label {
display: block;
font-weight: $font-weight-medium;
margin-bottom: 0.5rem
}
.form-check {
@extend %form-common;
@@ -135,7 +140,9 @@ $form-range-thumb-transition: background-color .3s ease-in-out, bor
}
}
.form-footer {
margin-top: 2rem;
}
.form-range {
@@ -346,3 +353,57 @@ $form-range-thumb-transition: background-color .3s ease-in-out, bor
border-color: $color-primary;
}
}
/* Switch */
.form-switch {
position: relative;
display: inline-block;
height: 1.5rem;
width: 2.75rem;
}
.form-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 1rem;
width: 1rem;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: $color-primary;
}
input:focus + .slider {
box-shadow: 0 0 1px $color-primary;
}
input:checked + .slider:before {
-webkit-transform: translateX(1rem);
-ms-transform: translateX(1rem);
transform: translateX(1rem);
}

View File

@@ -1,3 +1,7 @@
.row > * {
min-width: 0;
}
.container,
.container-fluid {
width: 100%;
@@ -10,6 +14,10 @@
}
}
.container-tight {
max-width: $container-tight-max-width;
}
.container-narrow {
max-width: $container-narrow-max-width;
}

View File

@@ -8,6 +8,40 @@ hr {
border-top: 1px solid $color-border-light;
}
.hr-text {
display: flex;
align-items: center;
margin: $hr-margin-y 0;
height: 1px;
&:after,
&:before {
flex: 1 1 auto;
height: 1px;
background-color: $color-border;
}
&:before {
content: "";
margin-right: .5rem;
}
&:after {
content: "";
margin-left: .5rem;
}
> *:first-child {
padding-right: .5rem;
padding-left: 0;
color: $color-border;
}
.card > & {
margin: 0;
}
}
a {
color: $color-primary;
text-decoration: none;

View File

@@ -1,3 +1,6 @@
import { type Session } from 'next-auth';
import { Prisma } from '@prisma/client'
export type IconType = {
name: string
tags: string[]
@@ -18,3 +21,37 @@ export type DocsItem = {
}
export type DocsConfigType = DocsItem[]
export type ExtendedSession = Session & { user?: { id?: string} }
const userWithRelations = Prisma.validator<Prisma.UserDefaultArgs>()({
include: { accounts: true, sessions: true, subscription: true },
})
export type User = Prisma.UserGetPayload<typeof userWithRelations>
const planWithRelations = Prisma.validator<Prisma.PlanDefaultArgs>()({
include: { subscriptions: true },
})
export type Plan = Prisma.PlanGetPayload<typeof planWithRelations>
const subscriptionWithRelations = Prisma.validator<Prisma.SubscriptionDefaultArgs>()({
include: { plan: true, user: true },
})
export type Subscription = Prisma.SubscriptionGetPayload<typeof subscriptionWithRelations>
export type SubscriptionState = {
id?: number,
planName?: string,
planInterval?: string,
productId?: number,
variantId?: number,
status?: string,
renewalDate?: Date | null,
trialEndDate?: Date | null,
expiryDate?: Date | null,
unpauseDate?: Date | null,
price?: number,
} | undefined

View File

@@ -109,7 +109,7 @@
},
{
"name": "Bonaire",
"flag": "bq"
"flag": "bq-bo"
},
{
"name": "Brazil",
@@ -263,10 +263,6 @@
"name": "Spain",
"flag": "es"
},
{
"name": "Catalonia",
"flag": "es-ct"
},
{
"name": "Ethiopia",
"flag": "et"
@@ -935,10 +931,6 @@
"name": "United States Minor Islands",
"flag": "um"
},
{
"name": "United Nations",
"flag": "un"
},
{
"name": "United States of America",
"flag": "us"

View File

@@ -1 +1 @@
{"version":"2.32.0","count":4637}
{"version":"2.35.0","count":4694}

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
{% capture class-attr %}class="{{ prefix }}-brand {{ prefix }}-brand-autodark{% if include.class %} {{ include.class }}{% endif %}"{% endcapture %}
{% if include.header %}
<h1 {{ class-attr }}>
<div {{ class-attr }}>
<a href="{{ site.base }}">
{% else %}
<a href="{{ site.base }}" {{ class-attr }}>
@@ -23,7 +23,7 @@
{% if include.header %}
</a>
</h1>
</div>
{% else %}
</a>
{% endif %}

View File

@@ -45,8 +45,14 @@
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="navbar"{% if include.dark-secondary %} data-bs-theme="dark"{% endif %}>
<div class="container-xl">
{% include layout/navbar-menu.html sample=include.sample hide-icons=include.hide-icons long-titles=true %}
{% include layout/navbar-search.html breakpoint=breakpoint class="my-2 my-md-0 flex-grow-1 flex-md-grow-0 order-first order-md-last" %}
<div class="row flex-fill align-items-center">
<div class="col">
{% include layout/navbar-menu.html sample=include.sample hide-icons=include.hide-icons long-titles=true %}
</div>
<div class="col-2 d-none d-xxl-block">
{% include layout/navbar-search.html breakpoint=breakpoint class="my-2 my-md-0 flex-grow-1 flex-md-grow-0 order-first order-md-last" %}
</div>
</div>
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ menu: base.flags
<div class="demo-icons-list">
{% for country in site.data.flags %}
<span class="demo-icons-list-item" title="{{ country.name }}" data-bs-toggle="tooltip" data-bs-placement="top">
<span class="flag flag-country-{{ country.code | downcase }}"></span>
<span class="flag flag-country-{{ country.flag | downcase }}"></span>
</span>
{% endfor %}
{% for icon in (0..20) %}

View File

@@ -3,13 +3,13 @@
//
$darken-dark: darken($dark, 2%) !default;
$lighten-dark: lighten($dark, 2%) !default;
$border-color-dark: lighten($dark, 4%) !default;
$border-color-dark: lighten($dark, 8%) !default;
$border-color-translucent-dark: rgba(72, 110, 149, .14) !default;
$border-dark-color-dark: lighten($dark, 4%) !default;
$border-color-active-dark: lighten($dark, 12%) !default;
$border-active-color-dark: lighten($dark, 12%) !default;
//new bootsrap variables
$body-color-dark: $light !default;
$body-color-dark: $gray-200 !default;
$body-emphasis-color-dark: $white !default;
$code-color-dark: var(--#{$prefix}gray-300) !default;

View File

@@ -135,15 +135,15 @@ $text-secondary-dark-opacity: 0.8 !default;
$border-opacity: 0.16 !default;
$border-light-opacity: 0.08 !default;
$border-dark-opacity: 0.24 !default;
$border-active-opacity: 0.48 !default;
$border-active-opacity: 0.58 !default;
$gray-50: #fcfdfe !default;
$gray-100: #f6f8fb !default;
$gray-200: #eef1f4 !default;
$gray-300: #dadfe5 !default;
$gray-400: #bbc3cd !default;
$gray-500: #929dab !default;
$gray-600: #667382 !default;
$gray-50: #f6f8fb !default;
$gray-100: #eef3f6 !default;
$gray-200: #dce1e7 !default;
$gray-300: #b8c4d4 !default;
$gray-400: #8a97ab !default;
$gray-500: #6c7a91 !default;
$gray-600: #49566c !default;
$gray-700: #3a4859 !default;
$gray-800: #182433 !default;
$gray-900: #040a11 !default;
@@ -159,14 +159,14 @@ $bg-surface-secondary: var(--#{$prefix}gray-100) !default;
$bg-surface-tertiary: var(--#{$prefix}gray-50) !default;
$bg-surface-dark: var(--#{$prefix}dark) !default;
$body-bg: $gray-100 !default;
$body-bg: $gray-50 !default;
$body-color: $dark !default;
$body-emphasis-color: $dark !default;
$body-emphasis-color: $gray-700 !default;
$color-contrast-dark: $body-color !default;
$color-contrast-light: $light !default;
$blue: #0054a6 !default;
$blue: #206bc4 !default;
$azure: #4299e1 !default;
$indigo: #4263eb !default;
$purple: #ae3ec9 !default;
@@ -179,7 +179,7 @@ $green: #2fb344 !default;
$teal: #0ca678 !default;
$cyan: #17a2b8 !default;
$color-blue: #0054a6;
$color-blue: #206bc4;
$color-azure: #3586c9;
$color-indigo: #4263eb;
$color-purple: #ae3ec9;
@@ -192,12 +192,12 @@ $color-green: #2fb344;
$color-teal: #0ca678;
$color-cyan: #17a2b8;
$text-secondary: $gray-600 !default;
$text-secondary-light: $gray-500 !default;
$text-secondary-dark: $gray-700 !default;
$text-secondary: $gray-500 !default;
$text-secondary-light: $gray-400 !default;
$text-secondary-dark: $gray-600 !default;
$border-color: $gray-300 !default;
$border-color-translucent: rgba(4, 32, 69, 0.14);
$border-color: $gray-200 !default;
$border-color-translucent: rgba(4, 32, 69, 0.1);
$border-dark-color: $gray-400 !default;
$border-dark-color-translucent: rgba(4, 32, 69, 0.27);
@@ -209,7 +209,7 @@ $active-bg: rgba(var(--#{$prefix}primary-rgb), 0.04) !default;
$active-color: var(--#{$prefix}primary) !default;
$active-border-color: var(--#{$prefix}primary) !default;
$hover-bg: rgba(var(--#{$prefix}text-secondary-rgb), 0.04) !default;
$hover-bg: rgba(var(--#{$prefix}secondary-rgb), 0.08) !default;
$disabled-bg: var(--#{$prefix}bg-surface-secondary) !default;
$disabled-color: var(--#{$prefix}gray-300) !default;
@@ -293,7 +293,7 @@ $border-radius-lg: 8px !default;
$border-radius-pill: 100rem !default;
// Icons
$icon-color: var(--#{$prefix}gray-500) !default;
$icon-color: var(--#{$prefix}gray-400) !default;
// Code
$code-color: var(--#{$prefix}gray-600) !default;
@@ -583,7 +583,7 @@ $card-border-color: var(--#{$prefix}border-color-translucent) !default;
$card-border-radius: var(--#{$prefix}border-radius) !default;
$card-spacer-x: 1.25rem !default;
$card-spacer-y: 1.25rem !default;
$card-spacer-y: 1rem !default;
$card-cap-bg: var(--#{$prefix}bg-surface-tertiary) !default;
$card-cap-color: inherit !default;
@@ -720,6 +720,8 @@ $nav-tabs-bg: var(--#{$prefix}bg-surface-tertiary) !default;
$navbar-height: 3.5rem !default;
$navbar-padding-y: 0.25rem !default;
$navbar-hover-color: $white !default;
$navbar-border-width: var(--#{$prefix}border-width) !default;
$navbar-border-color: var(--#{$prefix}border-color) !default;
@@ -757,7 +759,7 @@ $sidebar-width: 15rem !default;
// Page
$page-title-font-size: var(--#{$prefix}font-size-h2) !default;
$page-title-line-height: var(--#{$prefix}line-height-h4) !default;
$page-title-line-height: var(--#{$prefix}line-height-h2) !default;
$page-title-font-weight: var(--#{$prefix}font-weight-headings) !default;
// Popover

View File

@@ -43,8 +43,8 @@ body {
--#{$prefix}dark-mode-border-color-translucent
);
--#{$prefix}border-dark-color: var(--#{$prefix}dark-mode-border-dark-color);
--#{$prefix}border-color-active: var(
--#{$prefix}dark-mode-border-color-active
--#{$prefix}border-active-color: var(
--#{$prefix}dark-mode-border-active-color
);
--#{$prefix}btn-color: #{$darken-dark};

View File

@@ -6,10 +6,7 @@
}
.page-center {
.container {
margin-top: auto;
margin-bottom: auto;
}
justify-content: center;
}
.page-wrapper {

View File

@@ -45,7 +45,7 @@
--#{$prefix}dark-mode-border-color: #{$border-color-dark};
--#{$prefix}dark-mode-border-color-translucent: #{$border-color-translucent-dark};
--#{$prefix}dark-mode-border-color-active: #{$border-color-active-dark};
--#{$prefix}dark-mode-border-active-color: #{$border-active-color-dark};
--#{$prefix}dark-mode-border-dark-color: #{$border-dark-color-dark};
--#{$prefix}page-padding: #{$page-padding};

View File

@@ -7,7 +7,7 @@
--#{$prefix}btn-color: var(--#{$prefix}body-color);
--#{$prefix}btn-border-color: #{$btn-border-color};
--#{$prefix}btn-hover-bg: var(--#{$prefix}btn-bg);
--#{$prefix}btn-hover-border-color: var(--#{$prefix}border-color-active);
--#{$prefix}btn-hover-border-color: var(--#{$prefix}border-active-color);
--#{$prefix}btn-box-shadow: var(--#{$prefix}box-shadow-input);
--#{$prefix}btn-active-color: #{$active-color};
--#{$prefix}btn-active-bg: #{$active-bg};
@@ -50,7 +50,7 @@
}
.btn-link {
color: $link-color;
color: #{lighten($primary, 5%)};
background-color: transparent;
border-color: transparent;
box-shadow: none;
@@ -72,8 +72,8 @@
.btn-#{$color} {
@if $color == 'dark' {
--#{$prefix}btn-border-color: var(--#{$prefix}dark-mode-border-color);
--#{$prefix}btn-hover-border-color: var(--#{$prefix}dark-mode-border-color-active);
--#{$prefix}btn-active-border-color: var(--#{$prefix}dark-mode-border-color-active);
--#{$prefix}btn-hover-border-color: var(--#{$prefix}dark-mode-border-active-color);
--#{$prefix}btn-active-border-color: var(--#{$prefix}dark-mode-border-active-color);
} @else {
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-hover-border-color: transparent;

View File

@@ -1,5 +1,6 @@
.dropdown-menu {
user-select: none;
background-clip: border-box;
&.card {
padding: 0;

View File

@@ -1,5 +1,5 @@
$countries: (
'ad', 'af', 'ae', 'afrun', 'ag', 'ai', 'al', 'am', 'ams', 'ao', 'aq', 'ar', 'as', 'at', 'au', 'aw', 'ax', 'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bl', 'bm', 'bn', 'bo', 'bq-bo', 'bq-sa', 'bq-se', 'br', 'bs', 'bt', 'bv', 'bw', 'by', 'bz', 'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co', 'cr', 'cu', 'cv', 'cw', 'cx', 'cy', 'cz', 'de', 'dj', 'dk', 'dm', 'do', 'ec', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm', 'fo', 'fr', 'ga', 'gb-eng', 'gb-sct', 'gb', 'gb-wls', 'gb-nir', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm', 'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn', 'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir', 'is', 'it', 'je', 'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn-sk', 'kp', 'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 'ls', 'lt', 'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'me', 'mf', 'mg', 'mh', 'mk', 'ml', 'mm', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'nc', 'ne', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu', 'nz', 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr', 'ps', 'pt', 'pw', 'py', 'qa', 'rainbow', 're', 'ro', 'rs', 'ru', 'rw', 'sa', 'sb', 'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so', 'sr', 'ss', 'st', 'sv', 'sx', 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 'tk', 'tl', 'tm', 'tn', 'to', 'tr', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'um', 'unasur', 'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws', 'ye', 'za', 'zm', 'zw'
'ad', 'af', 'ae', 'afrun', 'ag', 'ai', 'al', 'am', 'ao', 'aq', 'ar', 'as', 'at', 'au', 'aw', 'ax', 'az', 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 'bj', 'bl', 'bm', 'bn', 'bo', 'bq-bo', 'bq-sa', 'bq-se', 'br', 'bs', 'bt', 'bv', 'bw', 'by', 'bz', 'ca', 'cc', 'cd', 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 'co', 'cr', 'cu', 'cv', 'cw', 'cx', 'cy', 'cz', 'de', 'dj', 'dk', 'dm', 'do', 'dz', 'ec', 'ee', 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 'fk', 'fm', 'fo', 'fr', 'ga', 'gb-eng', 'gb-sct', 'gb', 'gb-wls', 'gb-nir', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 'gl', 'gm', 'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 'gu', 'gw', 'gy', 'hk', 'hm', 'hn', 'hr', 'ht', 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 'ir', 'is', 'it', 'je', 'jm', 'jo', 'jp', 'ke', 'kg', 'kh', 'ki', 'km', 'kn', 'kp', 'kr', 'kw', 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 'ls', 'lt', 'lu', 'lv', 'ly', 'ma', 'mc', 'md', 'me', 'mf', 'mg', 'mh', 'mk', 'ml', 'mm', 'mn', 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 'mw', 'mx', 'my', 'mz', 'na', 'nc', 'ne', 'nf', 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu', 'nz', 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 'pm', 'pn', 'pr', 'ps', 'pt', 'pw', 'py', 'qa', 'rainbow', 're', 'ro', 'rs', 'ru', 'rw', 'sa', 'sb', 'sc', 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 'sm', 'sn', 'so', 'sr', 'ss', 'st', 'sv', 'sx', 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 'tk', 'tl', 'tm', 'tn', 'to', 'tr', 'tt', 'tv', 'tw', 'tz', 'ua', 'ug', 'um', 'unasur', 'us', 'uy', 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 'wf', 'ws', 'ye', 'za', 'zm', 'zw'
);
.flag {
@@ -28,4 +28,4 @@ $countries: (
.flag-#{$flag-size} {
height: map-get($size, size);
}
}
}

View File

@@ -156,7 +156,7 @@ Links
*/
[class^="link-"], [class*=" link-"] {
&.disabled {
color: $nav-link-disabled-color;
color: $nav-link-disabled-color !important;
pointer-events: none;
}
}

View File

@@ -30,7 +30,7 @@
}
.button-next-month,
.button-prev-month {
.button-previous-month {
cursor: pointer !important;
}