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.
- package/.editorconfig +9 -0
- package/.eslintrc.json +86 -0
- package/_example/.env-example +16 -0
- package/_example/client/config.ts +5 -0
- package/_example/client/css/index.css +35 -0
- package/_example/client/fonts/Roboto-Bold.ttf +0 -0
- package/_example/client/fonts/Roboto-BoldItalic.ttf +0 -0
- package/_example/client/fonts/Roboto-Italic.ttf +0 -0
- package/_example/client/fonts/Roboto-Medium.ttf +0 -0
- package/_example/client/fonts/Roboto-MediumItalic.ttf +0 -0
- package/_example/client/fonts/Roboto-Regular.ttf +0 -0
- package/_example/client/fonts/inter-v13-latin-300.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-500.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-600.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-700.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-800.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-900.woff2 +0 -0
- package/_example/client/fonts/inter-v13-latin-regular.woff2 +0 -0
- package/_example/client/imgs/android-chrome-512x512.png +0 -0
- package/_example/client/imgs/favicon.png +0 -0
- package/_example/client/imgs/icons/calendar.svg +3 -0
- package/_example/client/imgs/icons/email.svg +6 -0
- package/_example/client/imgs/icons/eye-open.svg +4 -0
- package/_example/client/imgs/icons/eye.svg +5 -0
- package/_example/client/imgs/icons/filter.svg +7 -0
- package/_example/client/imgs/icons/left-circle.svg +3 -0
- package/_example/client/imgs/icons/left.svg +3 -0
- package/_example/client/imgs/icons/line-options.svg +5 -0
- package/_example/client/imgs/icons/line.svg +3 -0
- package/_example/client/imgs/icons/person.svg +7 -0
- package/_example/client/imgs/icons/plus-circle.svg +5 -0
- package/_example/client/imgs/icons/plus.svg +5 -0
- package/_example/client/imgs/icons/right-circle.svg +3 -0
- package/_example/client/imgs/icons/right.svg +3 -0
- package/_example/client/imgs/icons/search.svg +3 -0
- package/_example/client/imgs/icons/shield.svg +6 -0
- package/_example/client/imgs/icons/tick-circle-solid.svg +8 -0
- package/_example/client/imgs/icons/tick-circle.svg +6 -0
- package/_example/client/imgs/icons/tick.svg +5 -0
- package/_example/client/imgs/icons/up2-small.svg +4 -0
- package/_example/client/imgs/icons/up2.svg +4 -0
- package/_example/client/imgs/icons/updown.svg +6 -0
- package/_example/client/imgs/icons/v-big-dark.svg +3 -0
- package/_example/client/imgs/icons/v-dark.svg +3 -0
- package/_example/client/imgs/icons/v.svg +3 -0
- package/_example/client/imgs/icons/v2-active.svg +6 -0
- package/_example/client/imgs/icons/x1.svg +4 -0
- package/_example/client/imgs/logo/logo-white.svg +20 -0
- package/_example/client/imgs/logo/logo.svg +20 -0
- package/_example/client/imgs/no-image.jpg +0 -0
- package/_example/client/imgs/user.jpg +0 -0
- package/_example/client/index.html +12 -0
- package/_example/client/index.ts +47 -0
- package/_example/components/auth.api.js +1 -0
- package/_example/components/index.tsx +225 -0
- package/_example/components/partials/layouts.tsx +5 -0
- package/_example/components/settings.api.js +1 -0
- package/_example/server/config.js +120 -0
- package/_example/server/email/welcome.html +27 -0
- package/_example/server/index.js +32 -0
- package/_example/tailwind.config.js +84 -0
- package/_example/tsconfig.json +32 -0
- package/_example/types.d.ts +7 -0
- package/_example/webpack.config.js +4 -0
- package/client/app.js +300 -0
- package/client/css/components.css +84 -0
- package/client/css/fonts.css +67 -0
- package/client/imgs/icons/calendar.svg +3 -0
- package/client/imgs/icons/email.svg +6 -0
- package/client/imgs/icons/eye-open.svg +4 -0
- package/client/imgs/icons/eye.svg +5 -0
- package/client/imgs/icons/filter.svg +7 -0
- package/client/imgs/icons/left-circle.svg +3 -0
- package/client/imgs/icons/left.svg +3 -0
- package/client/imgs/icons/line-options.svg +5 -0
- package/client/imgs/icons/line.svg +3 -0
- package/client/imgs/icons/person.svg +7 -0
- package/client/imgs/icons/plus-circle.svg +5 -0
- package/client/imgs/icons/plus.svg +5 -0
- package/client/imgs/icons/right-circle.svg +3 -0
- package/client/imgs/icons/right.svg +3 -0
- package/client/imgs/icons/search.svg +3 -0
- package/client/imgs/icons/shield.svg +6 -0
- package/client/imgs/icons/tick-circle-solid.svg +8 -0
- package/client/imgs/icons/tick-circle.svg +6 -0
- package/client/imgs/icons/tick.svg +5 -0
- package/client/imgs/icons/up2-small.svg +4 -0
- package/client/imgs/icons/up2.svg +4 -0
- package/client/imgs/icons/updown.svg +6 -0
- package/client/imgs/icons/v-big-dark.svg +3 -0
- package/client/imgs/icons/v-dark.svg +3 -0
- package/client/imgs/icons/v.svg +3 -0
- package/client/imgs/icons/v2-active.svg +6 -0
- package/client/imgs/icons/x1.svg +4 -0
- package/client.js +42 -0
- package/components/auth/auth.api.js +419 -0
- package/components/auth/reset.jsx +88 -0
- package/components/auth/signin.jsx +74 -0
- package/components/auth/signup.jsx +62 -0
- package/components/billing/stripe.api.js +267 -0
- package/components/partials/element/accordion.jsx +82 -0
- package/components/partials/element/avatar.jsx +28 -0
- package/components/partials/element/button.jsx +66 -0
- package/components/partials/element/dropdown.jsx +185 -0
- package/components/partials/element/initials.jsx +56 -0
- package/components/partials/element/message.jsx +124 -0
- package/components/partials/element/modal.jsx +229 -0
- package/components/partials/element/sidebar.jsx +166 -0
- package/components/partials/element/tooltip.jsx +146 -0
- package/components/partials/element/topbar.jsx +25 -0
- package/components/partials/form/checkbox.jsx +74 -0
- package/components/partials/form/drop-handler.jsx +62 -0
- package/components/partials/form/drop.jsx +125 -0
- package/components/partials/form/form-error.jsx +21 -0
- package/components/partials/form/input-color.jsx +77 -0
- package/components/partials/form/input-currency.jsx +133 -0
- package/components/partials/form/input-date.jsx +223 -0
- package/components/partials/form/input.jsx +131 -0
- package/components/partials/form/location.jsx +212 -0
- package/components/partials/form/select.jsx +369 -0
- package/components/partials/form/toggle.jsx +46 -0
- package/components/partials/is-first-render.js +15 -0
- package/components/partials/layout/layout1.jsx +32 -0
- package/components/partials/layout/layout2.jsx +47 -0
- package/components/partials/not-found.jsx +7 -0
- package/components/partials/styleguide.jsx +252 -0
- package/components/settings/settings-account.jsx +143 -0
- package/components/settings/settings-business.jsx +121 -0
- package/components/settings/settings-team--member.jsx +108 -0
- package/components/settings/settings-team.jsx +76 -0
- package/components/settings/settings.api.js +54 -0
- package/package.json +175 -0
- package/readme.md +43 -0
- package/server/email/index.js +192 -0
- package/server/email/partials/email.css +153 -0
- package/server/email/partials/layout1.swig +92 -0
- package/server/email/partials/line.swig +8 -0
- package/server/email/partials/vert-10.swig +8 -0
- package/server/email/partials/vert-15.swig +8 -0
- package/server/email/partials/vert-20.swig +8 -0
- package/server/email/partials/vert-25.swig +8 -0
- package/server/email/partials/vert-30.swig +8 -0
- package/server/email/partials/vert-35.swig +8 -0
- package/server/email/partials/vert-50.swig +8 -0
- package/server/email/reset-password.html +21 -0
- package/server/email/welcome.html +21 -0
- package/server/models/company.js +76 -0
- package/server/models/user.js +45 -0
- package/server/router.js +355 -0
- package/server.js +20 -0
- package/util.js +1145 -0
- 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
|
+
`
|