nitro-web 0.0.87 → 0.0.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/components/auth/auth.api.js +411 -0
  2. package/components/auth/reset.tsx +86 -0
  3. package/components/auth/signin.tsx +76 -0
  4. package/components/auth/signup.tsx +62 -0
  5. package/components/billing/stripe.api.js +268 -0
  6. package/components/dashboard/dashboard.tsx +32 -0
  7. package/components/partials/element/accordion.tsx +102 -0
  8. package/components/partials/element/avatar.tsx +40 -0
  9. package/components/partials/element/button.tsx +98 -0
  10. package/components/partials/element/calendar.tsx +125 -0
  11. package/components/partials/element/dropdown.tsx +248 -0
  12. package/components/partials/element/filters.tsx +194 -0
  13. package/components/partials/element/github-link.tsx +16 -0
  14. package/components/partials/element/initials.tsx +66 -0
  15. package/components/partials/element/message.tsx +141 -0
  16. package/components/partials/element/modal.tsx +90 -0
  17. package/components/partials/element/sidebar.tsx +195 -0
  18. package/components/partials/element/tooltip.tsx +154 -0
  19. package/components/partials/element/topbar.tsx +15 -0
  20. package/components/partials/form/checkbox.tsx +150 -0
  21. package/components/partials/form/drop-handler.tsx +68 -0
  22. package/components/partials/form/drop.tsx +141 -0
  23. package/components/partials/form/field-color.tsx +86 -0
  24. package/components/partials/form/field-currency.tsx +158 -0
  25. package/components/partials/form/field-date.tsx +252 -0
  26. package/components/partials/form/field.tsx +231 -0
  27. package/components/partials/form/form-error.tsx +27 -0
  28. package/components/partials/form/location.tsx +225 -0
  29. package/components/partials/form/select.tsx +360 -0
  30. package/components/partials/is-first-render.ts +14 -0
  31. package/components/partials/not-found.tsx +7 -0
  32. package/components/partials/styleguide.tsx +407 -0
  33. package/package.json +2 -1
@@ -0,0 +1,268 @@
1
+ // @ts-nocheck
2
+ import Stripe from 'stripe'
3
+ import db from 'monastery'
4
+ import * as util from 'nitro-web/util'
5
+
6
+ let stripe = undefined
7
+ let stripeProducts = []
8
+ let config = {}
9
+
10
+ export const routes = {
11
+ // Routes
12
+ 'post /api/stripe/webhook': [stripeWebhook],
13
+ 'post /api/stripe/create-billing-portal-session': [billingPortalSessionCreate],
14
+ 'get /api/stripe/upcoming-invoices': [upcomingInvoicesFind],
15
+
16
+ // Overridable helpers
17
+ setup: setup,
18
+ }
19
+
20
+ function setup(middleware, _config) {
21
+ // Set config values
22
+ config = {
23
+ env: _config.env,
24
+ clientUrl: _config.clientUrl,
25
+ stripeSecretKey: _config.stripeSecretKey,
26
+ stripeWebhookSecret: _config.stripeWebhookSecret,
27
+ }
28
+ for (let key in config) {
29
+ if (!config[key]) {
30
+ throw new Error(`Missing config value for stripe.api.js: ${key}`)
31
+ }
32
+ }
33
+ stripe = new Stripe(config.stripeSecretKey)
34
+ }
35
+
36
+ async function stripeWebhook(req, res) {
37
+ try {
38
+ var event = config.env == 'development' ? req.body : stripe.webhooks.constructEvent(
39
+ req.rawBody,
40
+ req.rawHeaders['stripe-signature'],
41
+ config.stripeWebhookSecret
42
+ )
43
+ } catch (err) {
44
+ if (err && err.message) console.log(err.message)
45
+ else console.log(err)
46
+ return res.error(err)
47
+ }
48
+
49
+ if (!event.data || !event.data.object) {
50
+ return res.status(400).send(`Missing webhook data: ${event}.`)
51
+ }
52
+
53
+ // useful for cleaning failed webhooks
54
+ if (req.query.success) return true
55
+ // console.log('event.type: ', event.type)
56
+
57
+ // Stripe cannot guarantee event order
58
+ switch (event.type) {
59
+ case 'customer.subscription.created':
60
+ case 'customer.subscription.updated':
61
+ case 'customer.subscription.deleted':
62
+ // Subscriptions can be renewed which resurrects cancelled subscriptions.. ignore this
63
+ console.log('Event: ' + event.type)
64
+ webhookSubUpdated(req, res, event)
65
+ break
66
+ case 'customer.created': // customer created by subscribing
67
+ case 'customer.updated': // payment method changes
68
+ console.log('Event: ' + event.type)
69
+ webhookCustomerCreatedUpdated(req, res, event)
70
+ break
71
+ default:
72
+ res.status(400).send(`Unsupported type: ${event}.`)
73
+ break
74
+ }
75
+ }
76
+
77
+ async function billingPortalSessionCreate(req, res) {
78
+ try {
79
+ if (!req.user.stripeCustomer?.id) {
80
+ throw new Error('No stripe customer found for the user')
81
+ }
82
+ const session = await stripe.billingPortal.sessions.create({
83
+ customer: req.user.stripeCustomer.id,
84
+ return_url: config.clientUrl + '/subscriptions',
85
+ })
86
+ res.json(session.url)
87
+ } catch (err) {
88
+ error(req, res, err)
89
+ }
90
+ }
91
+
92
+ async function upcomingInvoicesFind(req, res) {
93
+ try {
94
+ if (!req.user.stripeCustomer?.id) return res.json({})
95
+ const nextInvoice = await stripe.invoices.retrieveUpcoming({
96
+ customer: req.user.stripeCustomer.id,
97
+ })
98
+ res.json(nextInvoice)
99
+ } catch (err) {
100
+ if (err.code == 'invoice_upcoming_none') return res.json({})
101
+ error(req, res, err)
102
+ }
103
+ }
104
+
105
+ /* ---- Overridable helpers ------------------ */
106
+
107
+ async function error(req, res, err) {
108
+ if (err && err.response && err.response.body) console.log(err.response.body)
109
+ if (util.isString(err) && err.match(/Cannot find company with id/)) {
110
+ res.json({ user: 'no company found' })
111
+ } else res.error(err)
112
+ }
113
+
114
+ export async function getProducts() {
115
+ /**
116
+ * Returns all products and caches it on the app
117
+ * @returns {Array} products
118
+ */
119
+ try {
120
+ if (stripeProducts) return stripeProducts
121
+ if (!config.stripeSecretKey) {
122
+ stripeProducts = []
123
+ throw new Error('Missing process.env.stripeSecretKey for retrieving products')
124
+ }
125
+
126
+ let products = (await stripe.products.list({ limit: 100, active: true })).data
127
+ let prices = (await stripe.prices.list({ limit: 100, active: true, expand: ['data.tiers'] })).data
128
+
129
+ return (stripeProducts = products.map((product) => ({
130
+ // remove default_price when new pricing is ready
131
+ ...util.pick(product, ['id', 'created', 'default_price', 'description', 'name', 'metadata']),
132
+ type: product.name.match(/housing/i) ? 'project' : 'subscription', // overwriting, was 'service'
133
+ prices: prices
134
+ .filter((price) => price.product == product.id)
135
+ .map((price) => ({
136
+ ...util.pick(price, ['id', 'product', 'nickname', 'recurring', 'unit_amount', 'tiers', 'tiers_mode']),
137
+ interval: price.recurring?.interval, // 'year', 'month', undefined
138
+ })),
139
+ })))
140
+ } catch (err) {
141
+ console.error(new Error(err)) // when stripe throws errors, the callstack is missing.
142
+ return []
143
+ }
144
+ }
145
+
146
+ async function getUserFromEvent(event) {
147
+ // User retreived from the event's customer.
148
+ // The customer is created before the paymentIntent and subscriptionIntent is set up
149
+ let object = event.data.object
150
+ let customerId = object.object == 'customer'? object.id : object.customer
151
+ if (customerId) {
152
+ var user = await db.user.findOne({
153
+ query: { 'stripeCustomer.id': customerId },
154
+ populate: db.user.populate({}),
155
+ blacklist: false, // ['-company.users.inviteToken'],
156
+ })
157
+ }
158
+ if (!user) {
159
+ await db.log.insert({ data: {
160
+ date: Date.now(),
161
+ event: event.type,
162
+ message: `Cannot find user with id: ${customerId}.`,
163
+ }})
164
+ throw new Error(`Cannot find user with id: ${customerId}.`)
165
+ }
166
+ // populate company owner with user data (handy for _addSubscriptionBillingChange)
167
+ if (user.company?.users) {
168
+ user.company.users = user.company.users.map(o => {
169
+ if (o.role == 'owner' && o._id.toString() == user._id.toString()) {
170
+ o.firstName = user.firstName
171
+ o.name = user.name
172
+ o.email = user.email
173
+ }
174
+ return o
175
+ })
176
+ }
177
+ return user
178
+ }
179
+
180
+ async function webhookCustomerCreatedUpdated(req, res, event) {
181
+ try {
182
+ const customer = event.data.object
183
+ const user = await getUserFromEvent(event)
184
+ const customerExpanded = await stripe.customers.retrieve(
185
+ customer.id,
186
+ { expand: ['invoice_settings.default_payment_method'] }
187
+ )
188
+ await db.user.update({
189
+ query: user._id,
190
+ data: { stripeCustomer: customerExpanded },
191
+ blacklist: ['-stripeCustomer'],
192
+ })
193
+ res.json({})
194
+ } catch (err) {
195
+ console.log(err)
196
+ error(req, res, err)
197
+ }
198
+ }
199
+
200
+ async function webhookSubUpdated(req, res, event) {
201
+ // Update the subscription on the company
202
+ try {
203
+ const subData = event.data.object
204
+ // webhook from deleting a company?
205
+ if (subData.cancellation_details.comment == 'company deleted') {
206
+ return res.json({})
207
+ }
208
+
209
+ const user = await getUserFromEvent(event)
210
+ if (!user.company) {
211
+ throw new Error(`Subscription user has no company to update the subscription (${subData.id}) onto`)
212
+ }
213
+
214
+ // Ignoring incomplete subscriptions
215
+ if (subData.status.match(/incomplete/)) {
216
+ return res.json({})
217
+ // Ignoring subscriptions without companyId (e.g. manual subscriptions)
218
+ } else if (!subData.metadata.companyId) {
219
+ return res.json({ ignoringManualSubscriptions: true })
220
+ // Ignoring old subscriptions
221
+ } else if (subData.created < (user.company.stripeSubscription?.created || 0)) {
222
+ return res.json({ ignoringOldSubscriptions: true })
223
+ }
224
+
225
+ // Update company with the updated subscription and users
226
+ const sub = await stripe.subscriptions.retrieve(
227
+ subData.id,
228
+ { expand: ['latest_invoice.payment_intent'] }
229
+ )
230
+ await db.company.update({
231
+ query: user.company._id,
232
+ data: { stripeSubscription: sub, users: user.company.users },
233
+ blacklist: ['-stripeSubscription', '-users'],
234
+ })
235
+
236
+ res.json({})
237
+ } catch (err) {
238
+ console.error(err)
239
+ error(req, res, err)
240
+ }
241
+ }
242
+
243
+ // async function createOrUpdateCustomer(user, paymentMethod=null) {
244
+ // /**
245
+ // * Creates or updates a stripe customer and saves it to the user
246
+ // * @param {Object} user - user
247
+ // * @param {String} paymentMethod - stripe payment method id to save to the customer
248
+ // * @called before paymentIntent and subscriptionIntent, and after completion with paymentMethod
249
+ // * @returns mutates user
250
+ // */
251
+ // const data = {
252
+ // email: user.email,
253
+ // name: user.name,
254
+ // address: { country: 'NZ' },
255
+ // ...(!paymentMethod ? {} : { invoice_settings: { default_payment_method: paymentMethod }}),
256
+ // expand: ['invoice_settings.default_payment_method', 'tax'], // expands card object
257
+ // }
258
+
259
+ // if (user.stripeCustomer) var customer = await stripe.customers.update(user.stripeCustomer.id, data)
260
+ // else customer = await stripe.customers.create({ ...data })
261
+
262
+ // user.stripeCustomer = customer
263
+ // await db.user.update({
264
+ // query: user._id,
265
+ // data: { stripeCustomer: customer },
266
+ // blacklist: ['-stripeCustomer'],
267
+ // })
268
+ // }
@@ -0,0 +1,32 @@
1
+ import { css, theme } from 'twin.macro'
2
+ import { injectedConfig } from 'nitro-web'
3
+
4
+ export function Dashboard() {
5
+ const [store] = useTracked()
6
+ const textColor = store.apiAvailable ? 'text-green-700' : injectedConfig.isStatic ? 'text-gray-700' : 'text-pink-700'
7
+ const fillColor = store.apiAvailable ? 'fill-green-500' : injectedConfig.isStatic ? 'fill-gray-500' : 'fill-pink-500'
8
+ const bgColor = store.apiAvailable ? 'bg-green-100' : injectedConfig.isStatic ? 'bg-[#eeeeee]' : 'bg-pink-100'
9
+
10
+ return (
11
+ <div css={style}>
12
+ <h1 className="h1 mb-8">Dashboard</h1>
13
+ <p className="mb-4">
14
+ Welcome to Nitro, a modular React/Express base project, styled using Tailwind 🚀.
15
+ </p>
16
+ <p className="text-gray-700">
17
+ <span className={`inline-flex items-center gap-x-1.5 rounded-md ${bgColor} px-1.5 py-0.5 text-xs font-medium ${textColor}`}>
18
+ <svg viewBox="0 0 6 6" aria-hidden="true" className={`size-1.5 ${fillColor}`}>
19
+ <circle r={3} cx={3} cy={3} />
20
+ </svg>
21
+ { store.apiAvailable ? 'API Available' : `API Unavailable${injectedConfig.isStatic ? ' (Static Example)' : ''}` }
22
+ </span>
23
+ </p>
24
+ </div>
25
+ )
26
+ }
27
+
28
+ const style = css`
29
+ .example-usage-of-tailwind-variable {
30
+ color: ${theme('colors.primary')};
31
+ }
32
+ `
@@ -0,0 +1,102 @@
1
+
2
+ import { css } from 'twin.macro'
3
+ import { IsFirstRender } from 'nitro-web'
4
+
5
+ type AccordionProps = {
6
+ ariaControls?: string // pass to add aria-controls attribute to the accordion
7
+ children: React.ReactNode // first child is the header, second child is the contents
8
+ className?: string
9
+ classNameWhenExpanded?: string // handy for group styling
10
+ expanded?: boolean // initial value (or controlled value if onChange is passed)
11
+ onChange?: (event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>, index: number) => void
12
+ // called when the header is clicked
13
+ }
14
+
15
+ export function Accordion({ ariaControls, children, className, classNameWhenExpanded, expanded, onChange }: AccordionProps) {
16
+ const [preState, setPreState] = useState(expanded)
17
+ const [state, setState] = useState(expanded)
18
+ const [height, setHeight] = useState('auto')
19
+ const isFirst = IsFirstRender()
20
+ const el = useRef<HTMLDivElement>(null)
21
+ const style = css`
22
+ &>:last-child {
23
+ height: 0;
24
+ overflow: hidden;
25
+ transition: height ease 0.2s;
26
+ a, button {
27
+ visibility: hidden; /* removes from tab order */
28
+ transition: visibility 0s 0.2s;
29
+ }
30
+ }
31
+ &.is-expanded > *:last-child {
32
+ height: ${height.replace('-', '')};
33
+ a, button {
34
+ visibility: visible;
35
+ transition: visibility 0s;
36
+ }
37
+ }
38
+ `
39
+
40
+ useEffect(() => {
41
+ // Overrite local state
42
+ setPreState(expanded)
43
+ }, [expanded])
44
+
45
+ useEffect(() => {
46
+ // Calulcate height first before opening and closing
47
+ if (!isFirst) {
48
+ setHeight((_o) => el.current?.children[1].scrollHeight + 'px' + (preState ? '-' : ''))
49
+ }
50
+ }, [preState])
51
+
52
+ useEffect(() => {
53
+ // Open and close
54
+ if (height == 'auto') return
55
+ if (preState) var timeout = setTimeout(() => setHeight('auto'), 200)
56
+ // Wait for dom reflow after 2 frames
57
+ window.requestAnimationFrame(() => {
58
+ window.requestAnimationFrame(() => {
59
+ if (preState) setState(true)
60
+ else setState(false)
61
+ })
62
+ })
63
+ return () => timeout && clearTimeout(timeout)
64
+ }, [height])
65
+
66
+ const onClick = function(e: React.MouseEvent<HTMLDivElement>|React.KeyboardEvent<HTMLDivElement>) {
67
+ // Click came from inside the accordion header/summary
68
+ if (e.currentTarget.children[0].contains(e.target as HTMLElement) || e.currentTarget.children[0] == e.target) {
69
+ if (onChange) {
70
+ onChange(e, getElementIndex(e.currentTarget))
71
+ } else {
72
+ setPreState(o => !o)
73
+ }
74
+ }
75
+ }
76
+
77
+ const getElementIndex = function(node: HTMLElement) {
78
+ let index = 0
79
+ while ((node = node.previousElementSibling as HTMLElement)) index++
80
+ return index
81
+ }
82
+
83
+ const onKeyDown = function(e: React.KeyboardEvent<HTMLDivElement>) {
84
+ if (e.key === 'Enter' || e.key === ' ') {
85
+ onClick(e)
86
+ }
87
+ }
88
+
89
+ return (
90
+ <div
91
+ ref={el}
92
+ aria-controls={ariaControls}
93
+ aria-expanded={ariaControls ? state : undefined}
94
+ class={['accordion', className, state ? `is-expanded ${classNameWhenExpanded}` : '', 'nitro-accordion'].filter(o => o).join(' ')}
95
+ onClick={onClick}
96
+ onKeyDown={onKeyDown}
97
+ css={style}
98
+ >
99
+ {children}
100
+ </div>
101
+ )
102
+ }
@@ -0,0 +1,40 @@
1
+ import { Initials } from 'nitro-web'
2
+ import { s3Image } from 'nitro-web/util'
3
+ import avatarImg from 'nitro-web/client/imgs/avatar.jpg'
4
+ import { User } from 'nitro-web/types'
5
+
6
+ type AvatarProps = {
7
+ awsUrl: string
8
+ isRound?: boolean
9
+ user: User,
10
+ showPlaceholderImage?: boolean
11
+ className?: string
12
+ }
13
+
14
+ export function Avatar({ awsUrl, isRound, user, showPlaceholderImage, className }: AvatarProps) {
15
+ const classes = 'rounded-full w-[30px] h-[30px] object-cover transition-all duration-150 ease nitro-avatar ' + (className || '')
16
+
17
+ function getInitials(user: User) {
18
+ const text = (user.firstName ? [user.firstName, user.lastName] : (user?.name||'').split(' ')).map((o) => o?.charAt(0))
19
+ if (text.length == 1) return text[0] || ''
20
+ if (text.length > 1) return `${text[0]}${text[text.length - 1]}`
21
+ return ''
22
+ }
23
+
24
+ function getHex(user: User) {
25
+ const colors = ['#067306', '#AA33FF', '#FF54AF', '#F44336', '#c03c3c', '#7775f2', '#d88c1b']
26
+ const charIndex = (user.firstName||'a').toLowerCase().charCodeAt(0) - 97
27
+ const charIndexLimited = (charIndex < 0 || charIndex > 25) ? 25 : charIndex
28
+ const index = Math.round(charIndexLimited / 25 * (colors.length-1))
29
+ return colors[index]
30
+ }
31
+
32
+ return (
33
+ user.avatar
34
+ ? <img class={classes} src={s3Image(awsUrl, user.avatar, 'small')} />
35
+ : showPlaceholderImage
36
+ ? <img class={classes} src={avatarImg} width="30px" />
37
+ : <Initials className={classes} icon={{ initials: getInitials(user), hex: getHex(user) }} isRound={isRound} isMedium={true} />
38
+ )
39
+ }
40
+
@@ -0,0 +1,98 @@
1
+ import { twMerge } from 'nitro-web'
2
+ import { ChevronDown, ChevronUp } from 'lucide-react'
3
+
4
+ interface Button extends React.ButtonHTMLAttributes<HTMLButtonElement> {
5
+ color?: 'primary'|'secondary'|'black'|'dark'|'white'|'clear'|'custom'
6
+ size?: 'xs'|'sm'|'md'|'lg'|'custom'
7
+ customColor?: string
8
+ customSize?: string
9
+ className?: string
10
+ isLoading?: boolean
11
+ IconLeft?: React.ReactNode|'v'
12
+ IconLeftEnd?: React.ReactNode|'v'
13
+ IconRight?: React.ReactNode|'v'
14
+ IconRightEnd?: React.ReactNode|'v'
15
+ IconCenter?: React.ReactNode|'v'
16
+ children?: React.ReactNode|'v'
17
+ }
18
+
19
+ export function Button({
20
+ size='md',
21
+ color='primary',
22
+ customColor,
23
+ customSize,
24
+ className,
25
+ isLoading,
26
+ IconLeft,
27
+ IconLeftEnd,
28
+ IconRight,
29
+ IconRightEnd,
30
+ IconCenter,
31
+ children,
32
+ type='button',
33
+ ...props
34
+ }: Button) {
35
+ // const size = (color.match(/xs|sm|md|lg/)?.[0] || 'md') as 'xs'|'sm'|'md'|'lg'
36
+ const iconPosition =
37
+ IconLeft ? 'left' : IconLeftEnd ? 'leftEnd' : IconRight ? 'right' : IconRightEnd ? 'rightEnd' : IconCenter ? 'center' : 'none'
38
+ const base =
39
+ 'relative inline-flex items-center justify-center text-center font-medium shadow-sm focus-visible:outline ' +
40
+ 'focus-visible:outline-2 focus-visible:outline-offset-2 ring-inset ring-1' + (children ? '' : ' aspect-square')
41
+
42
+ // Button colors, you can use custom colors by using className instead
43
+ const colors = {
44
+ 'primary': 'bg-primary hover:bg-primary-hover ring-transparent text-white [&>.loader]:border-white',
45
+ 'secondary': 'bg-secondary hover:bg-secondary-hover ring-transparent text-white [&>.loader]:border-white',
46
+ 'black': 'bg-black hover:bg-gray-800 ring-transparent text-white [&>.loader]:border-white',
47
+ 'dark': 'bg-gray-800 hover:bg-gray-700 ring-transparent text-white [&>.loader]:border-white',
48
+ 'white': 'bg-white hover:bg-gray-50 ring-gray-300 text-gray-900 [&>.loader]:border-black', // maybe change to text-foreground
49
+ 'clear': 'hover:bg-gray-50 ring-gray-300 text-foreground [&>.loader]:border-foreground !shadow-none',
50
+ }
51
+
52
+ // Button sizes (px is better for height consistency)
53
+ const sizes = {
54
+ 'xs': 'px-[6px] h-[25px] text-xs !text-button-xs rounded',
55
+ 'sm': 'px-[10px] h-[32px] text-md text-button-base rounded-md',
56
+ 'md': 'px-[12px] h-[38px] text-md text-button-base rounded-md', // default
57
+ 'lg': 'px-[18px] h-[42px] text-md text-button-base rounded-md',
58
+ }
59
+
60
+ const appliedColor = color === 'custom' ? customColor : colors[color]
61
+ const appliedSize = size === 'custom' ? customSize : sizes[size]
62
+ const contentLayout = `gap-x-1.5 ${iconPosition == 'none' ? '' : ''}`
63
+ const loading = isLoading ? '[&>*]:opacity-0 text-opacity-0' : ''
64
+
65
+ function getIcon(Icon: React.ReactNode | 'v') {
66
+ if (Icon == 'v' || Icon == 'down') return <ChevronDown size={16.5} className="mt-[-1.4rem] mb-[-1.5rem]" />
67
+ if (Icon == '^' || Icon == 'up') return <ChevronUp size={16.5} className="mt-[-1.4rem] mb-[-1.5rem]" />
68
+ else return Icon
69
+ }
70
+
71
+ return (
72
+ <button
73
+ type={type}
74
+ class={twMerge(`${base} ${appliedSize} ${appliedColor} ${contentLayout} ${loading} nitro-button ${className||''}`)}
75
+ {...props}
76
+ >
77
+ {
78
+ IconCenter &&
79
+ <span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
80
+ {getIcon(IconCenter)}
81
+ </span>
82
+ }
83
+ {(IconLeft || IconLeftEnd) && getIcon(IconLeft || IconLeftEnd)}
84
+ <span class={`flex items-center ${iconPosition == 'leftEnd' || iconPosition == 'rightEnd' ? 'flex-1 justify-center' : ''}`}>
85
+ <span className="w-0">&nbsp;</span> {/* for min-height */}
86
+ {children}
87
+ </span>
88
+ {(IconRight || IconRightEnd) && getIcon(IconRight || IconRightEnd)}
89
+ {
90
+ isLoading &&
91
+ <span className={
92
+ 'loader !opacity-100 absolute top-[50%] left-[50%] w-[1rem] h-[1rem] ml-[-0.5rem] mt-[-0.5rem] ' +
93
+ 'rounded-full animate-spin border-2 !border-t-transparent'
94
+ } />
95
+ }
96
+ </button>
97
+ )
98
+ }
@@ -0,0 +1,125 @@
1
+ import { DayPicker, getDefaultClassNames } from 'react-day-picker'
2
+ import { isValid } from 'date-fns'
3
+ import 'react-day-picker/style.css'
4
+ import { IsFirstRender } from 'nitro-web'
5
+
6
+ export const dayButtonClassName = 'size-[33px] text-sm'
7
+
8
+ type Mode = 'single'|'multiple'|'range'
9
+ type ModeSelection<T extends Mode> = (
10
+ T extends 'single' ? Date | undefined
11
+ : T extends 'multiple' ? Date[]
12
+ : { from?: Date; to?: Date }
13
+ )
14
+
15
+ export type CalendarProps = {
16
+ mode?: Mode
17
+ onChange?: (mode: Mode, value: null|number|(null|number)[]) => void
18
+ value?: null|number|string|(null|number|string)[]
19
+ numberOfMonths?: number
20
+ month?: number // the value may be updated from an outside source, thus the month may have changed
21
+ className?: string
22
+ preserveTime?: boolean // just for single mode
23
+ }
24
+
25
+ export function Calendar({ mode='single', onChange, value, numberOfMonths, month: monthProp, className, preserveTime }: CalendarProps) {
26
+ const isFirstRender = IsFirstRender()
27
+ const isRange = mode == 'range'
28
+
29
+ // Convert the value to an array of valid* dates
30
+ const dates = useMemo(() => {
31
+ const _dates = Array.isArray(value) ? value : [value]
32
+ return _dates.map(date => isValid(date) ? new Date(date as number) : undefined) ////change to null
33
+ }, [value])
34
+
35
+ // Hold the month in state to control the calendar when the input changes
36
+ const [month, setMonth] = useState(dates[0])
37
+
38
+ // Update the month if its changed from an outside source
39
+ useEffect(() => {
40
+ if (!isFirstRender && monthProp) setMonth(new Date(monthProp))
41
+ }, [monthProp])
42
+
43
+ function handleDayPickerSelect<T extends Mode>(newDate: ModeSelection<T>) {
44
+ switch (mode as T) {
45
+ case 'single': {
46
+ const date = newDate as ModeSelection<'single'>
47
+ preserveTimeFn(date)
48
+ onChange?.(mode, date?.getTime() ?? null)
49
+ break
50
+ }
51
+ case 'range': {
52
+ const { from, to } = (newDate ?? {}) as ModeSelection<'range'>
53
+ onChange?.(mode, from ? [from.getTime() || null, to?.getTime() || null] : null)
54
+ break
55
+ }
56
+ case 'multiple': {
57
+ const dates = (newDate as ModeSelection<'multiple'>)?.filter(Boolean) ?? []
58
+ onChange?.(mode, dates.map((d) => d.getTime()))
59
+ break
60
+ }
61
+ }
62
+ }
63
+
64
+ function preserveTimeFn(date?: Date) {
65
+ // Preserve time from the original date if needed
66
+ if (preserveTime && dates[0] && date) {
67
+ const originalDate = dates[0]
68
+ date.setHours(
69
+ originalDate.getHours(),
70
+ originalDate.getMinutes(),
71
+ originalDate.getSeconds(),
72
+ originalDate.getMilliseconds()
73
+ )
74
+ }
75
+ }
76
+
77
+ const d = getDefaultClassNames()
78
+ const common = {
79
+ month: month,
80
+ onMonthChange: setMonth,
81
+ onSelect: handleDayPickerSelect,
82
+ numberOfMonths: numberOfMonths || (isRange ? 2 : 1),
83
+ modifiersClassNames: {
84
+ // Add a class without _, TW seems to replace this with a space in the css definition, e.g. &:not(.range middle)
85
+ range_middle: `${d.range_middle} rangemiddle`,
86
+ },
87
+ classNames: {
88
+ root: `${d.root} flex nitro-calendar`,
89
+ months: `${d.months} flex-nowrap`,
90
+ month_caption: `${d.month_caption} text-2xs pl-2`,
91
+ caption_label: `${d.caption_label} z-auto`,
92
+ button_previous: `${d.button_previous} size-8`,// [&:hover>svg]:fill-input-border-focus`,
93
+ button_next: `${d.button_next} size-8`,// [&:hover>svg]:fill-input-border-focus`,
94
+ chevron: `${d.chevron} fill-black size-[18px]`,
95
+
96
+ // Days
97
+ weekday: `${d.weekday} text-[11px] font-bold uppercase`,
98
+ day: `${d.day} size-[33px]`,
99
+ day_button: `${d.day_button} ${dayButtonClassName}`,
100
+
101
+ // States
102
+ focused: `${d.focused} [&>button]:bg-gray-200 [&>button]:border-gray-200`,
103
+ range_start: `${d.range_start} [&>button]:!bg-input-border-focus [&>button]:!border-input-border-focus`,
104
+ range_end: `${d.range_end} [&>button]:!bg-input-border-focus [&>button]:!border-input-border-focus`,
105
+ selected: `${d.selected} font-normal `
106
+ + '[&:not(.rangemiddle)>button]:!text-white '
107
+ + '[&:not(.rangemiddle)>button]:!bg-input-border-focus '
108
+ + '[&:not(.rangemiddle)>button]:!border-input-border-focus ',
109
+ },
110
+ }
111
+
112
+ return (
113
+ <div>
114
+ {
115
+ mode === 'single' ? (
116
+ <DayPicker mode="single" selected={dates[0]} {...common} className={className} />
117
+ ) : mode === 'range' ? (
118
+ <DayPicker mode="range" selected={{ from: dates[0], to: dates[1] }} {...common} className={className} />
119
+ ) : (
120
+ <DayPicker mode="multiple" selected={dates.filter((d) => !!d)} {...common} className={className} />
121
+ )
122
+ }
123
+ </div>
124
+ )
125
+ }