nitro-web 0.0.1

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 (152) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintrc.json +86 -0
  3. package/_example/.env-example +16 -0
  4. package/_example/client/config.ts +5 -0
  5. package/_example/client/css/index.css +35 -0
  6. package/_example/client/fonts/Roboto-Bold.ttf +0 -0
  7. package/_example/client/fonts/Roboto-BoldItalic.ttf +0 -0
  8. package/_example/client/fonts/Roboto-Italic.ttf +0 -0
  9. package/_example/client/fonts/Roboto-Medium.ttf +0 -0
  10. package/_example/client/fonts/Roboto-MediumItalic.ttf +0 -0
  11. package/_example/client/fonts/Roboto-Regular.ttf +0 -0
  12. package/_example/client/fonts/inter-v13-latin-300.woff2 +0 -0
  13. package/_example/client/fonts/inter-v13-latin-500.woff2 +0 -0
  14. package/_example/client/fonts/inter-v13-latin-600.woff2 +0 -0
  15. package/_example/client/fonts/inter-v13-latin-700.woff2 +0 -0
  16. package/_example/client/fonts/inter-v13-latin-800.woff2 +0 -0
  17. package/_example/client/fonts/inter-v13-latin-900.woff2 +0 -0
  18. package/_example/client/fonts/inter-v13-latin-regular.woff2 +0 -0
  19. package/_example/client/imgs/android-chrome-512x512.png +0 -0
  20. package/_example/client/imgs/favicon.png +0 -0
  21. package/_example/client/imgs/icons/calendar.svg +3 -0
  22. package/_example/client/imgs/icons/email.svg +6 -0
  23. package/_example/client/imgs/icons/eye-open.svg +4 -0
  24. package/_example/client/imgs/icons/eye.svg +5 -0
  25. package/_example/client/imgs/icons/filter.svg +7 -0
  26. package/_example/client/imgs/icons/left-circle.svg +3 -0
  27. package/_example/client/imgs/icons/left.svg +3 -0
  28. package/_example/client/imgs/icons/line-options.svg +5 -0
  29. package/_example/client/imgs/icons/line.svg +3 -0
  30. package/_example/client/imgs/icons/person.svg +7 -0
  31. package/_example/client/imgs/icons/plus-circle.svg +5 -0
  32. package/_example/client/imgs/icons/plus.svg +5 -0
  33. package/_example/client/imgs/icons/right-circle.svg +3 -0
  34. package/_example/client/imgs/icons/right.svg +3 -0
  35. package/_example/client/imgs/icons/search.svg +3 -0
  36. package/_example/client/imgs/icons/shield.svg +6 -0
  37. package/_example/client/imgs/icons/tick-circle-solid.svg +8 -0
  38. package/_example/client/imgs/icons/tick-circle.svg +6 -0
  39. package/_example/client/imgs/icons/tick.svg +5 -0
  40. package/_example/client/imgs/icons/up2-small.svg +4 -0
  41. package/_example/client/imgs/icons/up2.svg +4 -0
  42. package/_example/client/imgs/icons/updown.svg +6 -0
  43. package/_example/client/imgs/icons/v-big-dark.svg +3 -0
  44. package/_example/client/imgs/icons/v-dark.svg +3 -0
  45. package/_example/client/imgs/icons/v.svg +3 -0
  46. package/_example/client/imgs/icons/v2-active.svg +6 -0
  47. package/_example/client/imgs/icons/x1.svg +4 -0
  48. package/_example/client/imgs/logo/logo-white.svg +20 -0
  49. package/_example/client/imgs/logo/logo.svg +20 -0
  50. package/_example/client/imgs/no-image.jpg +0 -0
  51. package/_example/client/imgs/user.jpg +0 -0
  52. package/_example/client/index.html +12 -0
  53. package/_example/client/index.ts +47 -0
  54. package/_example/components/auth.api.js +1 -0
  55. package/_example/components/index.tsx +225 -0
  56. package/_example/components/partials/layouts.tsx +5 -0
  57. package/_example/components/settings.api.js +1 -0
  58. package/_example/server/config.js +120 -0
  59. package/_example/server/email/welcome.html +27 -0
  60. package/_example/server/index.js +32 -0
  61. package/_example/tailwind.config.js +84 -0
  62. package/_example/tsconfig.json +32 -0
  63. package/_example/types.d.ts +7 -0
  64. package/_example/webpack.config.js +4 -0
  65. package/client/app.js +300 -0
  66. package/client/css/components.css +84 -0
  67. package/client/css/fonts.css +67 -0
  68. package/client/imgs/icons/calendar.svg +3 -0
  69. package/client/imgs/icons/email.svg +6 -0
  70. package/client/imgs/icons/eye-open.svg +4 -0
  71. package/client/imgs/icons/eye.svg +5 -0
  72. package/client/imgs/icons/filter.svg +7 -0
  73. package/client/imgs/icons/left-circle.svg +3 -0
  74. package/client/imgs/icons/left.svg +3 -0
  75. package/client/imgs/icons/line-options.svg +5 -0
  76. package/client/imgs/icons/line.svg +3 -0
  77. package/client/imgs/icons/person.svg +7 -0
  78. package/client/imgs/icons/plus-circle.svg +5 -0
  79. package/client/imgs/icons/plus.svg +5 -0
  80. package/client/imgs/icons/right-circle.svg +3 -0
  81. package/client/imgs/icons/right.svg +3 -0
  82. package/client/imgs/icons/search.svg +3 -0
  83. package/client/imgs/icons/shield.svg +6 -0
  84. package/client/imgs/icons/tick-circle-solid.svg +8 -0
  85. package/client/imgs/icons/tick-circle.svg +6 -0
  86. package/client/imgs/icons/tick.svg +5 -0
  87. package/client/imgs/icons/up2-small.svg +4 -0
  88. package/client/imgs/icons/up2.svg +4 -0
  89. package/client/imgs/icons/updown.svg +6 -0
  90. package/client/imgs/icons/v-big-dark.svg +3 -0
  91. package/client/imgs/icons/v-dark.svg +3 -0
  92. package/client/imgs/icons/v.svg +3 -0
  93. package/client/imgs/icons/v2-active.svg +6 -0
  94. package/client/imgs/icons/x1.svg +4 -0
  95. package/client.js +42 -0
  96. package/components/auth/auth.api.js +419 -0
  97. package/components/auth/reset.jsx +88 -0
  98. package/components/auth/signin.jsx +74 -0
  99. package/components/auth/signup.jsx +62 -0
  100. package/components/billing/stripe.api.js +267 -0
  101. package/components/partials/element/accordion.jsx +82 -0
  102. package/components/partials/element/avatar.jsx +28 -0
  103. package/components/partials/element/button.jsx +66 -0
  104. package/components/partials/element/dropdown.jsx +185 -0
  105. package/components/partials/element/initials.jsx +56 -0
  106. package/components/partials/element/message.jsx +124 -0
  107. package/components/partials/element/modal.jsx +229 -0
  108. package/components/partials/element/sidebar.jsx +166 -0
  109. package/components/partials/element/tooltip.jsx +146 -0
  110. package/components/partials/element/topbar.jsx +25 -0
  111. package/components/partials/form/checkbox.jsx +74 -0
  112. package/components/partials/form/drop-handler.jsx +62 -0
  113. package/components/partials/form/drop.jsx +125 -0
  114. package/components/partials/form/form-error.jsx +21 -0
  115. package/components/partials/form/input-color.jsx +77 -0
  116. package/components/partials/form/input-currency.jsx +133 -0
  117. package/components/partials/form/input-date.jsx +223 -0
  118. package/components/partials/form/input.jsx +131 -0
  119. package/components/partials/form/location.jsx +212 -0
  120. package/components/partials/form/select.jsx +369 -0
  121. package/components/partials/form/toggle.jsx +46 -0
  122. package/components/partials/is-first-render.js +15 -0
  123. package/components/partials/layout/layout1.jsx +32 -0
  124. package/components/partials/layout/layout2.jsx +47 -0
  125. package/components/partials/not-found.jsx +7 -0
  126. package/components/partials/styleguide.jsx +252 -0
  127. package/components/settings/settings-account.jsx +143 -0
  128. package/components/settings/settings-business.jsx +121 -0
  129. package/components/settings/settings-team--member.jsx +108 -0
  130. package/components/settings/settings-team.jsx +76 -0
  131. package/components/settings/settings.api.js +54 -0
  132. package/package.json +175 -0
  133. package/readme.md +43 -0
  134. package/server/email/index.js +192 -0
  135. package/server/email/partials/email.css +153 -0
  136. package/server/email/partials/layout1.swig +92 -0
  137. package/server/email/partials/line.swig +8 -0
  138. package/server/email/partials/vert-10.swig +8 -0
  139. package/server/email/partials/vert-15.swig +8 -0
  140. package/server/email/partials/vert-20.swig +8 -0
  141. package/server/email/partials/vert-25.swig +8 -0
  142. package/server/email/partials/vert-30.swig +8 -0
  143. package/server/email/partials/vert-35.swig +8 -0
  144. package/server/email/partials/vert-50.swig +8 -0
  145. package/server/email/reset-password.html +21 -0
  146. package/server/email/welcome.html +21 -0
  147. package/server/models/company.js +76 -0
  148. package/server/models/user.js +45 -0
  149. package/server/router.js +355 -0
  150. package/server.js +20 -0
  151. package/util.js +1145 -0
  152. package/webpack.config.js +302 -0
@@ -0,0 +1,267 @@
1
+ import Stripe from 'stripe'
2
+ import db from 'monastery'
3
+ import * as util from '../../util.js'
4
+
5
+ let stripe = undefined
6
+ let stripeProducts = []
7
+ let config = {}
8
+
9
+ export default {
10
+ routes: {
11
+ 'post /api/stripe/webhook': ['stripeWebhook'],
12
+ 'post /api/stripe/create-billing-portal-session': ['isUser', 'billingPortalSessionCreate'],
13
+ 'get /api/stripe/upcoming-invoices': ['isUser', 'upcomingInvoicesFind'],
14
+ },
15
+
16
+ setup: function (middleware, _config) {
17
+ // Set config values
18
+ config = {
19
+ env: _config.env,
20
+ clientUrl: _config.clientUrl,
21
+ stripeSecretKey: _config.stripeSecretKey,
22
+ stripeWebhookSecret: _config.stripeWebhookSecret,
23
+ }
24
+ for (let key in config) {
25
+ if (!config[key]) {
26
+ throw new Error(`Missing config value for stripe.api.js: ${key}`)
27
+ }
28
+ }
29
+ stripe = new Stripe(config.stripeSecretKey)
30
+ },
31
+
32
+ stripeWebhook: async function (req, res) {
33
+ try {
34
+ var event = config.env == 'development' ? req.body : stripe.webhooks.constructEvent(
35
+ req.rawBody,
36
+ req.rawHeaders['stripe-signature'],
37
+ config.stripeWebhookSecret
38
+ )
39
+ } catch (err) {
40
+ if (err && err.message) console.log(err.message)
41
+ else console.log(err)
42
+ return res.error(err)
43
+ }
44
+
45
+ if (!event.data || !event.data.object) {
46
+ return res.status(400).send(`Missing webhook data: ${event}.`)
47
+ }
48
+
49
+ // useful for cleaning failed webhooks
50
+ if (req.query.success) return true
51
+ // console.log('event.type: ', event.type)
52
+
53
+ // Stripe cannot guarantee event order
54
+ switch (event.type) {
55
+ case 'customer.subscription.created':
56
+ case 'customer.subscription.updated':
57
+ case 'customer.subscription.deleted':
58
+ // Subscriptions can be renewed which resurrects cancelled subscriptions.. ignore this
59
+ console.log('Event: ' + event.type)
60
+ this._webhookSubUpdated(req, res, event)
61
+ break
62
+ case 'customer.created': // customer created by subscribing
63
+ case 'customer.updated': // payment method changes
64
+ console.log('Event: ' + event.type)
65
+ this._webhookCustomerCreatedUpdated(req, res, event)
66
+ break
67
+ default:
68
+ res.status(400).send(`Unsupported type: ${event}.`)
69
+ break
70
+ }
71
+ },
72
+
73
+ billingPortalSessionCreate: async function (req, res) {
74
+ try {
75
+ if (!req.user.stripeCustomer?.id) {
76
+ throw new Error('No stripe customer found for the user')
77
+ }
78
+ const session = await stripe.billingPortal.sessions.create({
79
+ customer: req.user.stripeCustomer.id,
80
+ return_url: config.clientUrl + '/subscriptions',
81
+ })
82
+ res.json(session.url)
83
+ } catch (err) {
84
+ this._error(req, res, err)
85
+ }
86
+ },
87
+
88
+ upcomingInvoicesFind: async function (req, res) {
89
+ try {
90
+ if (!req.user.stripeCustomer?.id) return res.json({})
91
+ const nextInvoice = await stripe.invoices.retrieveUpcoming({
92
+ customer: req.user.stripeCustomer.id,
93
+ })
94
+ res.json(nextInvoice)
95
+ } catch (err) {
96
+ if (err.code == 'invoice_upcoming_none') return res.json({})
97
+ this._error(req, res, err)
98
+ }
99
+ },
100
+
101
+ /* Private webhook actions */
102
+
103
+ _webhookCustomerCreatedUpdated: async function (req, res, event) {
104
+ try {
105
+ const customer = event.data.object
106
+ const user = await this._getUserFromEvent(event)
107
+ const customerExpanded = await stripe.customers.retrieve(
108
+ customer.id,
109
+ { expand: ['invoice_settings.default_payment_method'] }
110
+ )
111
+ await db.user.update({
112
+ query: user._id,
113
+ data: { stripeCustomer: customerExpanded },
114
+ blacklist: ['-stripeCustomer'],
115
+ })
116
+ res.json({})
117
+ } catch (err) {
118
+ console.log(err)
119
+ this._error(req, res, err)
120
+ }
121
+ },
122
+
123
+ _webhookSubUpdated: async function (req, res, event) {
124
+ // Update the subscription on the company
125
+ try {
126
+ const subData = event.data.object
127
+ // webhook from deleting a company?
128
+ if (subData.cancellation_details.comment == 'company deleted') {
129
+ return res.json({})
130
+ }
131
+
132
+ const user = await this._getUserFromEvent(event)
133
+ if (!user.company) {
134
+ throw new Error(`Subscription user has no company to update the subscription (${subData.id}) onto`)
135
+ }
136
+
137
+ // Ignoring incomplete subscriptions
138
+ if (subData.status.match(/incomplete/)) {
139
+ return res.json({})
140
+ // Ignoring subscriptions without companyId (e.g. manual subscriptions)
141
+ } else if (!subData.metadata.companyId) {
142
+ return res.json({ ignoringManualSubscriptions: true })
143
+ // Ignoring old subscriptions
144
+ } else if (subData.created < (user.company.stripeSubscription?.created || 0)) {
145
+ return res.json({ ignoringOldSubscriptions: true })
146
+ }
147
+
148
+ // Update company with the updated subscription and users
149
+ const sub = await stripe.subscriptions.retrieve(
150
+ subData.id,
151
+ { expand: ['latest_invoice.payment_intent'] }
152
+ )
153
+ await db.company.update({
154
+ query: user.company._id,
155
+ data: { stripeSubscription: sub, users: user.company.users },
156
+ blacklist: ['-stripeSubscription', '-users'],
157
+ })
158
+
159
+ res.json({})
160
+ } catch (err) {
161
+ console.error(err)
162
+ this._error(req, res, err)
163
+ }
164
+ },
165
+
166
+ /* Private */
167
+
168
+ _getUserFromEvent: async function (event) {
169
+ // User retreived from the event's customer.
170
+ // The customer is created before the paymentIntent and subscriptionIntent is set up
171
+ let object = event.data.object
172
+ let customerId = object.object == 'customer'? object.id : object.customer
173
+ if (customerId) {
174
+ var user = await db.user.findOne({
175
+ query: { 'stripeCustomer.id': customerId },
176
+ populate: db.user.populate({}),
177
+ blacklist: false, // ['-company.users.inviteToken'],
178
+ })
179
+ }
180
+ if (!user) {
181
+ await db.log.insert({ data: {
182
+ date: Date.now(),
183
+ event: event.type,
184
+ message: `Cannot find user with id: ${customerId}.`,
185
+ }})
186
+ throw new Error(`Cannot find user with id: ${customerId}.`)
187
+ }
188
+ // populate company owner with user data (handy for _addSubscriptionBillingChange)
189
+ if (user.company?.users) {
190
+ user.company.users = user.company.users.map(o => {
191
+ if (o.role == 'owner' && o._id.toString() == user._id.toString()) {
192
+ o.firstName = user.firstName
193
+ o.name = user.name
194
+ o.email = user.email
195
+ }
196
+ return o
197
+ })
198
+ }
199
+ return user
200
+ },
201
+
202
+ _getProducts: async function () {
203
+ /**
204
+ * Returns all products and caches it on the app
205
+ * @returns {Array} products
206
+ */
207
+ try {
208
+ if (stripeProducts) return stripeProducts
209
+ if (!config.stripeSecretKey) {
210
+ stripeProducts = []
211
+ throw new Error('Missing process.env.stripeSecretKey for retrieving products')
212
+ }
213
+
214
+ let products = (await stripe.products.list({ limit: 100, active: true })).data
215
+ let prices = (await stripe.prices.list({ limit: 100, active: true, expand: ['data.tiers'] })).data
216
+
217
+ return (stripeProducts = products.map((product) => ({
218
+ // remove default_price when new pricing is ready
219
+ ...util.pick(product, ['id', 'created', 'default_price', 'description', 'name', 'metadata']),
220
+ type: product.name.match(/housing/i) ? 'project' : 'subscription', // overwriting, was 'service'
221
+ prices: prices
222
+ .filter((price) => price.product == product.id)
223
+ .map((price) => ({
224
+ ...util.pick(price, ['id', 'product', 'nickname', 'recurring', 'unit_amount', 'tiers', 'tiers_mode']),
225
+ interval: price.recurring?.interval, // 'year', 'month', undefined
226
+ })),
227
+ })))
228
+ } catch (err) {
229
+ console.error(new Error(err)) // when stripe throws errors, the callstack is missing.
230
+ return []
231
+ }
232
+ },
233
+
234
+ _createOrUpdateCustomer: async function (user, paymentMethod=null) {
235
+ /**
236
+ * Creates or updates a stripe customer and saves it to the user
237
+ * @param {Object} user - user
238
+ * @param {String} paymentMethod - stripe payment method id to save to the customer
239
+ * @called before paymentIntent and subscriptionIntent, and after completion with paymentMethod
240
+ * @returns mutates user
241
+ */
242
+ const data = {
243
+ email: user.email,
244
+ name: user.name,
245
+ address: { country: 'NZ' },
246
+ ...(!paymentMethod ? {} : { invoice_settings: { default_payment_method: paymentMethod }}),
247
+ expand: ['invoice_settings.default_payment_method', 'tax'], // expands card object
248
+ }
249
+
250
+ if (user.stripeCustomer) var customer = await stripe.customers.update(user.stripeCustomer.id, data)
251
+ else customer = await stripe.customers.create({ ...data })
252
+
253
+ user.stripeCustomer = customer
254
+ await db.user.update({
255
+ query: user._id,
256
+ data: { stripeCustomer: customer },
257
+ blacklist: ['-stripeCustomer'],
258
+ })
259
+ },
260
+
261
+ _error: async function (req, res, err) {
262
+ if (err && err.response && err.response.body) console.log(err.response.body)
263
+ if (util.isString(err) && err.match(/Cannot find company with id/)) {
264
+ res.json({ user: 'no company found' })
265
+ } else res.error(err)
266
+ },
267
+ }
@@ -0,0 +1,82 @@
1
+ import { css } from 'twin.macro'
2
+ import { IsFirstRender } from '../is-first-render.js'
3
+
4
+ export function Accordion({ children, className, expanded, onChange }) {
5
+ /**
6
+ * @param {rxjs} children - first child is the header, second child is the contents
7
+ * <Accordion>
8
+ * <div>Header</div><div>Contents</div>
9
+ * </Accordion>
10
+ * @param {boolean} <expanded> - initial value (or controlled value if onChange is passed)
11
+ * @param {function} <onChange> - called when the header is clicked
12
+ */
13
+ let [preState, setPreState] = useState(expanded)
14
+ let [state, setState] = useState(expanded)
15
+ let [height, setHeight] = useState('auto')
16
+ let isFirst = IsFirstRender()
17
+ let el = useRef()
18
+ let style = () => css`
19
+ &>:last-child {
20
+ height: 0;
21
+ overflow: hidden;
22
+ transition: height ease 0.2s;
23
+ }
24
+ &.is-expanded > div:last-child {
25
+ height: ${height.replace('-', '')};
26
+ }
27
+ `
28
+
29
+ useEffect(() => {
30
+ // Overrite local state
31
+ setPreState(expanded)
32
+ }, [expanded])
33
+
34
+ useEffect(() => {
35
+ // Calulcate height first before opening and closing
36
+ if (!isFirst) {
37
+ setHeight((_o) => el.current.children[1].scrollHeight + 'px' + (preState ? '-' : ''))
38
+ }
39
+ }, [preState])
40
+
41
+ useEffect(() => {
42
+ // Open and close
43
+ if (height == 'auto') return
44
+ if (preState) var timeout = setTimeout(() => setHeight('auto'), 200)
45
+ // Wait for dom reflow after 2 frames
46
+ window.requestAnimationFrame(() => {
47
+ window.requestAnimationFrame(() => {
48
+ if (preState) setState(true)
49
+ else setState(false)
50
+ })
51
+ })
52
+ return () => timeout && clearTimeout(timeout)
53
+ }, [height])
54
+
55
+ let onClick = function(e) {
56
+ // Click came from inside the accordion header/summary
57
+ if (e.currentTarget.children[0].contains(e.target) || e.currentTarget.children[0] == e.target) {
58
+ if (onChange) {
59
+ onChange(e, getElementIndex(e.currentTarget))
60
+ } else {
61
+ setPreState(o => !o)
62
+ }
63
+ }
64
+ }
65
+
66
+ let getElementIndex = function(node) {
67
+ let index = 0
68
+ while ((node = node.previousElementSibling)) index++
69
+ return index
70
+ }
71
+
72
+ return (
73
+ <div
74
+ ref={el}
75
+ class={['accordion', className, state ? 'is-expanded' : ''].filter(o => o).join(' ')}
76
+ onClick={onClick}
77
+ css={style}
78
+ >
79
+ {children}
80
+ </div>
81
+ )
82
+ }
@@ -0,0 +1,28 @@
1
+ import { s3Image } from '../../../util.js'
2
+ import { Initials } from './initials.jsx'
3
+
4
+ export function Avatar({ awsUrl, isRound, user, showPlaceholderImage, className }) {
5
+ const classes = 'rounded-full w-[30px] h-[30px] object-cover transition-all duration-150 ease ' + (className || '')
6
+
7
+ function getInitials(user) {
8
+ const text = (user.firstName ? [user.firstName, user.lastName] : user.name.split(' ')).map((o) => o.charAt(0))
9
+ if (text.length == 1) return text[0]
10
+ if (text.length > 1) return `${text[0]}${text[text.length - 1]}`
11
+ return ''
12
+ }
13
+
14
+ function getHex(user) {
15
+ let colors = ['#067306', '#AA33FF', '#FF54AF', '#F44336', '#c03c3c', '#7775f2', '#d88c1b']
16
+ let charIndex = (user.firstName||'a').toLowerCase().charCodeAt(0) - 97
17
+ let charIndexLimited = (charIndex < 0 || charIndex > 25) ? 25 : charIndex
18
+ let index = Math.round(charIndexLimited / 25 * (colors.length-1))
19
+ return colors[index]
20
+ }
21
+
22
+ return (
23
+ user.avatar
24
+ ? <img class={classes} src={s3Image(awsUrl, user.avatar, 'small')} />
25
+ : showPlaceholderImage ? <img class={classes} src="/assets/imgs/icons/user.svg" width="30px" />
26
+ : <Initials class={classes} icon={{ initials: getInitials(user), hex: getHex(user) }} isRound={isRound} isMedium={true} />
27
+ )
28
+ }
@@ -0,0 +1,66 @@
1
+ // todo: add loading indicator
2
+ import { ChevronDownIcon } from '@heroicons/react/20/solid'
3
+ /**
4
+ * @param {'primary'|'secondary'|'white'|'primary-sm'|'secondary-sm'|'white-sm'} [type='primary']
5
+ * @param {string} [className]
6
+ * @param {boolean} [isLoading]
7
+ * @param {React.ReactNode|string} [IconLeft]
8
+ * @param {React.ReactNode|string} [IconRight]
9
+ * @param {React.ReactNode|string} [IconRight2]
10
+ * @param {React.ReactNode|string} [children]
11
+ */
12
+ export function Button({ color='primary', className, isLoading, IconLeft, IconRight, IconRight2, children, ...props }) {
13
+ const size = color.match(/xs|sm|md|lg/) || 'md'
14
+ const iconPosition = IconLeft ? 'left' : IconRight ? 'right' : IconRight2 ? 'right2' : 'none'
15
+ const base = 'relative inline-block font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2'
16
+
17
+ // Button types
18
+ const primary = 'bg-primary text-white shadow-sm hover:bg-primary-hover focus-visible:outline-primary'
19
+ const secondary = 'bg-secondary text-white shadow-sm hover:bg-secondary-hover focus-visible:outline-secondary'
20
+ const white = 'bg-white text-gray-900 ring-1 ring-inset ring-gray-300 shadow-sm hover:bg-gray-50'
21
+
22
+ // Button sizes
23
+ const sizes = {
24
+ xs: 'px-2 py-1 text-xs rounded',
25
+ sm: 'px-2.5 py-1.5 text-sm rounded-md',
26
+ md: 'px-3 py-2 text-sm rounded-md',
27
+ lg: 'px-3.5 py-2.5 text-sm rounded-md',
28
+ }
29
+
30
+ // Icon position
31
+ const contentLayouts = {
32
+ left: 'w-full inline-flex items-center',
33
+ right: 'w-full inline-flex items-center',
34
+ right2: 'w-full inline-flex items-center justify-between',
35
+ none: 'w-full ',
36
+ }
37
+
38
+ if (color.match(/primary/)) var colorAndSize = `${primary} ${sizes[size]}`
39
+ else if (color.match(/secondary/)) colorAndSize = `${secondary} ${sizes[size]}`
40
+ else if (color.match(/white/)) colorAndSize = `${white} ${sizes[size]}`
41
+
42
+ var contentLayout = `${contentLayouts[iconPosition]}`
43
+ if (!(className||'').match(/gap-/)) contentLayout += ' gap-x-1.5'
44
+
45
+ function getIcon(Icon, className) {
46
+ if (Icon == 'v') return <ChevronDownIcon className={className} />
47
+ else return Icon
48
+ }
49
+
50
+ return (
51
+ <button class={`${base} ${colorAndSize} ${className||''}`} {...props}>
52
+ <span class={`${contentLayout} ${isLoading ? 'opacity-0' : ''}`}>
53
+ {IconLeft && getIcon(IconLeft, 'size-6 -my-6 -mx-1')}
54
+ {children}
55
+ {IconRight && getIcon(IconRight, 'size-6 -my-6 -mx-1')}
56
+ {IconRight2 && getIcon(IconRight2, 'size-6 -my-6 -mx-1')}
57
+ </span>
58
+ {
59
+ isLoading &&
60
+ <span class="absolute inset-0 flex items-center justify-center">
61
+ <span className="w-4 h-4 rounded-full animate-spin border-2 border-t-transparent border-white" />
62
+ </span>
63
+ }
64
+ </button>
65
+ )
66
+ }
@@ -0,0 +1,185 @@
1
+ import { css } from 'twin.macro'
2
+ import { cloneElement } from 'react'
3
+ import { toArray } from '../../../util.js'
4
+ import { forwardRef } from 'react'
5
+ import { getSelectStyle } from '../form/select.jsx'
6
+ import { CheckCircleIcon } from '@heroicons/react/24/solid'
7
+
8
+
9
+ /**
10
+ * Dropdown component
11
+ *
12
+ * @param {boolean} animate
13
+ * @param {React.ReactNode} children
14
+ * @param {string} className
15
+ * @param {'bottom-left'|'bottom-right'|'top-left'|'top-right'} [dir='bottom-left'] - The direction of the menu
16
+ * @param {[{ label, onClick, isSelected, icon, className }]} options - Menu options
17
+ * @param {boolean} isHoverable - Whether the dropdown is hoverable
18
+ * @param {number} minWidth - The minimum width of the menu
19
+ * @param {React.ReactNode} menuChildren - The content to render inside the top of the dropdown
20
+ * @param {boolean} menuIsOpen - Whether the menu is open
21
+ * @param {boolean} menuToggles - Whether the menu toggles
22
+ * @param {function} toggleCallback - The callback function to call when the menu is toggled
23
+ */
24
+ export const Dropdown = forwardRef(function Dropdown({
25
+ animate=true,
26
+ children,
27
+ className,
28
+ dir,
29
+ options,
30
+ isHoverable,
31
+ minWidth,
32
+ menuChildren,
33
+ menuIsOpen,
34
+ menuToggles=true,
35
+ toggleCallback,
36
+ }, ref) {
37
+ // https://letsbuildui.dev/articles/building-a-dropdown-menu-component-with-react-hooks
38
+ isHoverable = isHoverable && !menuIsOpen
39
+ const dropdownRef = useRef(null)
40
+ const [isActive, setIsActive] = useState(menuIsOpen)
41
+ const menuStyle = getSelectStyle({ name: 'menu', usePrefixes: true })
42
+
43
+ // Expose the setIsActive function to the parent component
44
+ useImperativeHandle(ref, () => ({ setIsActive }))
45
+
46
+ useEffect(() => {
47
+ const pageClick = (e) => {
48
+ try {
49
+ // If the active element exists and is clicked outside of the dropdown, toggle the dropdown
50
+ if (dropdownRef.current !== null && !dropdownRef.current.contains(e.target)) setIsActive(!isActive)
51
+ } catch (_e) {
52
+ // Errors throw for contains() when the user clicks off the webpage when open
53
+ setIsActive(!isActive)
54
+ }
55
+ }
56
+ if (isActive && !menuIsOpen) {
57
+ // Wait for the next event loop in the case of mousedown'ing the dropdown, while loosing click focus from a checkbox
58
+ setTimeout(() => {
59
+ window.addEventListener('mousedown', pageClick)
60
+ window.addEventListener('focus', pageClick, true) // true needed to capture focus events
61
+ }, 0)
62
+ }
63
+ return () => {
64
+ window.removeEventListener('mousedown', pageClick)
65
+ window.removeEventListener('focus', pageClick, true) // true needed to capture focus events
66
+ }
67
+ }, [isActive, dropdownRef])
68
+
69
+ useEffect(() => {
70
+ if (toggleCallback) toggleCallback(isActive)
71
+ }, [isActive])
72
+
73
+ function onMouseDown(e) {
74
+ if (e.key && e.key != 'Enter') return
75
+ if (e.key) e.preventDefault() // for button, stops buttons firing twice
76
+ if (!isHoverable && !menuIsOpen && ((menuToggles || e.key) || !isActive)) setIsActive(!isActive)
77
+ }
78
+
79
+ function onClick(option, e) {
80
+ if (option.onClick) option.onClick(e)
81
+ if (!menuIsOpen) setIsActive(!isActive)
82
+ }
83
+
84
+ return (
85
+ <div
86
+ class={
87
+ 'relative' +
88
+ (dir ? ` is-${dir}` : ' is-bottom-left') +
89
+ (isHoverable ? ' is-hoverable' : '') +
90
+ (isActive ? ' is-active' : '') +
91
+ (!animate ? ' no-animation' : '') +
92
+ (className ? ` ${className}` : '')
93
+ }
94
+ onClick={(e) => e.stopPropagation()} // required for dropdowns inside row links
95
+ ref={dropdownRef}
96
+ css={style}
97
+ >
98
+ {
99
+ toArray(children).map((el, key) => {
100
+ const onKeyDown = onMouseDown
101
+ if (!el.type) throw new Error('Dropdown component requires a valid child element')
102
+ return cloneElement(el, { key, onMouseDown, onKeyDown }) // adds onClick
103
+ })
104
+ }
105
+ <ul
106
+ style={{ minWidth }}
107
+ class={`${menuStyle} absolute invisible opacity-0 select-none min-w-full z-[1]`}
108
+ >
109
+ {menuChildren}
110
+ {
111
+ options && options.map((option, i) => {
112
+ const optionStyle = getSelectStyle({ name: 'option', usePrefixes: true, isSelected: option.isSelected })
113
+ return (
114
+ <li
115
+ key={i}
116
+ className={`${optionStyle} ${option.className}`}
117
+ onClick={(e) => onClick(option, e)}
118
+ >
119
+ <span class="flex-auto">{option.label}</span>
120
+ { !!option.icon && option.icon }
121
+ { option.isSelected && <CheckCircleIcon className="size-[22px] text-primary -my-1 -mx-1" /> }
122
+ </li>
123
+ )
124
+ })
125
+ }
126
+ </ul>
127
+ </div>
128
+ )
129
+ })
130
+
131
+ const style = () => css`
132
+ ul {
133
+ transition: transform 0.15s ease, opacity 0.15s ease, visibility 0s 0.15s ease;
134
+ }
135
+ &.is-bottom-right,
136
+ &.is-top-right {
137
+ ul {
138
+ left: auto;
139
+ right: 0;
140
+ }
141
+ }
142
+ &.is-bottom-left,
143
+ &.is-bottom-right {
144
+ ul {
145
+ top: 100%;
146
+ transform: translateY(6px);
147
+ }
148
+ }
149
+ &.is-top-left,
150
+ &.is-top-right {
151
+ ul {
152
+ bottom: 100%;
153
+ transform: translateY(-10px);
154
+ }
155
+ }
156
+ // active submenu
157
+ &.is-hoverable:hover,
158
+ &:focus,
159
+ &.is-active,
160
+ li:hover,
161
+ li:focus,
162
+ li.is-active {
163
+ ul {
164
+ opacity: 1;
165
+ visibility: visible;
166
+ transition: transform 0.15s ease, opacity 0.15s ease;
167
+ }
168
+ &.is-bottom-left > ul,
169
+ &.is-bottom-right > ul {
170
+ transform: translateY(3px) !important;
171
+ }
172
+ &.is-top-left > ul,
173
+ &.is-top-right > ul {
174
+ transform: translateY(-7px) !important;
175
+ }
176
+ }
177
+ // no animation
178
+ &.no-animation {
179
+ ul {
180
+ transition: none;
181
+ }
182
+ }
183
+ `
184
+
185
+
@@ -0,0 +1,56 @@
1
+ import { css } from 'twin.macro'
2
+
3
+ export function Initials({ icon, isBig, isMedium, isSmall, isRound, className }) {
4
+ return (
5
+ <span
6
+ css={style}
7
+ class={
8
+ 'initials-square' +
9
+ (isBig ? ' is-big' : '') +
10
+ (isMedium ? ' is-medium' : '') +
11
+ (isSmall ? ' is-small' : '') +
12
+ (isRound ? ' is-round' : '') +
13
+ (icon ? '' : ' is-empty') +
14
+ (className ? ' ' + className : '')
15
+ }
16
+ style={icon ? {backgroundColor: icon?.hex + '15', color: icon?.hex} : {}}
17
+ >
18
+ {icon?.initials}
19
+ </span>
20
+ )
21
+ }
22
+
23
+ const style = (_theme) => css`
24
+ // seen in input.jsx
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ border-radius: 5px;
29
+ font-weight: 700;
30
+ font-size: 11px;
31
+ width: 24px;
32
+ height: 24px;
33
+ // new
34
+ &.is-medium {
35
+ width: 30px;
36
+ height: 30px;
37
+ font-size: 12px;
38
+ }
39
+ // seen in select.jsx
40
+ &.is-small {
41
+ width: 22px;
42
+ height: 22px;
43
+ font-size: 11px;
44
+ }
45
+ &.is-big {
46
+ width: 48px;
47
+ height: 48px;
48
+ font-size: 14px;
49
+ }
50
+ &.is-round {
51
+ border-radius: 50%;
52
+ }
53
+ &.is-empty {
54
+ width: 0;
55
+ }
56
+ `