mirror of
https://github.com/tabler/tabler.git
synced 2025-12-21 17:34:25 +04:00
Compare commits
56 Commits
v1.0.0-bet
...
dev-site-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95891cb108 | ||
|
|
09a8e2d2c5 | ||
|
|
e4c76be517 | ||
|
|
9bbdba9c67 | ||
|
|
4ef2d125c2 | ||
|
|
7193a70102 | ||
|
|
442ac3bb4b | ||
|
|
877182140d | ||
|
|
80f5732d1a | ||
|
|
4dada97651 | ||
|
|
dd7547a9a9 | ||
|
|
1d24683563 | ||
|
|
7a138cf02e | ||
|
|
9500a0a0b0 | ||
|
|
274f6433d0 | ||
|
|
4a4fc50127 | ||
|
|
b09d280fbe | ||
|
|
efea7e0f7c | ||
|
|
9ea00ed58b | ||
|
|
981d69baec | ||
|
|
69449024e8 | ||
|
|
6975ab5956 | ||
|
|
b203b9c1a4 | ||
|
|
77c5127446 | ||
|
|
1831c45d88 | ||
|
|
e6e5ffc544 | ||
|
|
e639a20353 | ||
|
|
1c78a7d705 | ||
|
|
4da52d719e | ||
|
|
f3cfcc4fc1 | ||
|
|
9fb40197b9 | ||
|
|
f2e182dedf | ||
|
|
2d0b051e26 | ||
|
|
c462b74773 | ||
|
|
dfbef814cd | ||
|
|
9772160071 | ||
|
|
aeff172a41 | ||
|
|
5a2123fa6c | ||
|
|
2931c72341 | ||
|
|
7e62c3a563 | ||
|
|
d673851db5 | ||
|
|
020255f161 | ||
|
|
ec345edd9d | ||
|
|
5edc93384c | ||
|
|
0efbb01e55 | ||
|
|
361e81e478 | ||
|
|
ebda434060 | ||
|
|
8ffe0e6a1a | ||
|
|
5250158600 | ||
|
|
e307ba44fb | ||
|
|
8cf5058456 | ||
|
|
b7c772ce1b | ||
|
|
1f0e6e074a | ||
|
|
a76df72359 | ||
|
|
90f4931c96 | ||
|
|
33bbc46229 |
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
72
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||
---
|
||||
@@ -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
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -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"
|
||||
|
||||
43
Gemfile.lock
43
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"presets": ["next/babel"],
|
||||
"plugins": []
|
||||
"plugins": ["@babel/plugin-transform-private-methods"]
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
3
site/.gitignore
vendored
@@ -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/*
|
||||
18
site/app/(entry)/layout.tsx
Normal file
18
site/app/(entry)/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
site/app/(entry)/signin/page.tsx
Normal file
10
site/app/(entry)/signin/page.tsx
Normal 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/>;
|
||||
}
|
||||
10
site/app/(entry)/signup/page.tsx
Normal file
10
site/app/(entry)/signup/page.tsx
Normal 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/>;
|
||||
}
|
||||
41
site/app/(marketing)/billing/change-plan/page.tsx
Normal file
41
site/app/(marketing)/billing/change-plan/page.tsx
Normal 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">← 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>
|
||||
)
|
||||
}
|
||||
28
site/app/(marketing)/billing/page.tsx
Normal file
28
site/app/(marketing)/billing/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
site/app/(marketing)/billing/refresh-plans/page.tsx
Normal file
107
site/app/(marketing)/billing/refresh-plans/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
site/app/(marketing)/billing/webhook/route.ts
Normal file
152
site/app/(marketing)/billing/webhook/route.ts
Normal 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');
|
||||
}
|
||||
5
site/app/api/auth/[...nextauth]/route.ts
Normal file
5
site/app/api/auth/[...nextauth]/route.ts
Normal 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};
|
||||
55
site/app/api/checkouts/route.ts
Normal file
55
site/app/api/checkouts/route.ts
Normal 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})
|
||||
}
|
||||
}
|
||||
42
site/app/api/register/route.ts
Normal file
42
site/app/api/register/route.ts
Normal 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 }
|
||||
);
|
||||
// }
|
||||
}
|
||||
121
site/app/api/subscriptions/[id]/route.ts
Normal file
121
site/app/api/subscriptions/[id]/route.ts
Normal 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 })
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
292
site/components/Manage.tsx
Normal 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} />
|
||||
)
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
11
site/components/NextAuthProvider.tsx
Normal file
11
site/components/NextAuthProvider.tsx
Normal 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
122
site/components/Plan.tsx
Normal 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
|
||||
124
site/components/PlanButton.tsx
Normal file
124
site/components/PlanButton.tsx
Normal 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
154
site/components/Signin.tsx
Normal 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
113
site/components/Signup.tsx
Normal 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> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
238
site/components/Subscription.tsx
Normal file
238
site/components/Subscription.tsx
Normal 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 →
|
||||
</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'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 →
|
||||
</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'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'
|
||||
})
|
||||
}
|
||||
32
site/components/layout/EntryHeader.tsx
Normal file
32
site/components/layout/EntryHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
112
site/lib/auth.ts
Normal 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
50
site/lib/data.ts
Normal 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
14
site/lib/prisma.ts
Normal 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
3
site/middleware.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from 'next-auth/middleware';
|
||||
|
||||
export const config = { matcher: ['/billing'] };
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
996
site/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
138
site/prisma/migrations/20231206000400_init/migration.sql
Normal file
138
site/prisma/migrations/20231206000400_init/migration.sql
Normal 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;
|
||||
3
site/prisma/migrations/migration_lock.toml
Normal file
3
site/prisma/migrations/migration_lock.toml
Normal 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
107
site/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -21,6 +21,10 @@ Cards
|
||||
.card-body {
|
||||
padding: $gap-4;
|
||||
flex: 1;
|
||||
|
||||
.card-md > & {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -92,5 +92,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
div.example {
|
||||
iframe.example-frame {
|
||||
min-height: 7rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
}
|
||||
|
||||
.page-center {
|
||||
.container {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-wrapper {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.dropdown-menu {
|
||||
user-select: none;
|
||||
background-clip: border-box;
|
||||
|
||||
&.card {
|
||||
padding: 0;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ Links
|
||||
*/
|
||||
[class^="link-"], [class*=" link-"] {
|
||||
&.disabled {
|
||||
color: $nav-link-disabled-color;
|
||||
color: $nav-link-disabled-color !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
2
src/scss/vendor/_litepicker.scss
vendored
2
src/scss/vendor/_litepicker.scss
vendored
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
.button-next-month,
|
||||
.button-prev-month {
|
||||
.button-previous-month {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user