nitro-web 0.0.85 → 0.0.87
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/client/globals.ts +10 -6
- package/package.json +14 -6
- package/types/{required-globals.d.ts → globals.d.ts} +3 -1
- package/.editorconfig +0 -9
- package/components/auth/auth.api.js +0 -410
- package/components/auth/reset.tsx +0 -86
- package/components/auth/signin.tsx +0 -76
- package/components/auth/signup.tsx +0 -62
- package/components/billing/stripe.api.js +0 -268
- package/components/dashboard/dashboard.tsx +0 -32
- package/components/partials/element/accordion.tsx +0 -102
- package/components/partials/element/avatar.tsx +0 -40
- package/components/partials/element/button.tsx +0 -98
- package/components/partials/element/calendar.tsx +0 -125
- package/components/partials/element/dropdown.tsx +0 -248
- package/components/partials/element/filters.tsx +0 -194
- package/components/partials/element/github-link.tsx +0 -16
- package/components/partials/element/initials.tsx +0 -66
- package/components/partials/element/message.tsx +0 -141
- package/components/partials/element/modal.tsx +0 -90
- package/components/partials/element/sidebar.tsx +0 -195
- package/components/partials/element/tooltip.tsx +0 -154
- package/components/partials/element/topbar.tsx +0 -15
- package/components/partials/form/checkbox.tsx +0 -150
- package/components/partials/form/drop-handler.tsx +0 -68
- package/components/partials/form/drop.tsx +0 -141
- package/components/partials/form/field-color.tsx +0 -86
- package/components/partials/form/field-currency.tsx +0 -158
- package/components/partials/form/field-date.tsx +0 -252
- package/components/partials/form/field.tsx +0 -231
- package/components/partials/form/form-error.tsx +0 -27
- package/components/partials/form/location.tsx +0 -225
- package/components/partials/form/select.tsx +0 -360
- package/components/partials/is-first-render.ts +0 -14
- package/components/partials/not-found.tsx +0 -7
- package/components/partials/styleguide.tsx +0 -407
- package/semver-updater.cjs +0 -13
- package/tsconfig.json +0 -38
- package/tsconfig.types.json +0 -15
- package/types/core-only-globals.d.ts +0 -9
- package/types.ts +0 -60
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { Button, Field, FormError, Topbar, request, injectedConfig } from 'nitro-web'
|
|
2
|
-
import { Errors } from 'nitro-web/types'
|
|
3
|
-
|
|
4
|
-
export function Signup() {
|
|
5
|
-
const navigate = useNavigate()
|
|
6
|
-
const isLoading = useState(false)
|
|
7
|
-
const [, setStore] = useTracked()
|
|
8
|
-
const [state, setState] = useState({
|
|
9
|
-
email: injectedConfig.env === 'development' ? (injectedConfig.placeholderEmail || '') : '',
|
|
10
|
-
name: injectedConfig.env === 'development' ? 'Bruce Wayne' : '',
|
|
11
|
-
business: { name: injectedConfig.env === 'development' ? 'Wayne Enterprises' : '' },
|
|
12
|
-
password: injectedConfig.env === 'development' ? '1234' : '',
|
|
13
|
-
errors: [] as Errors,
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
async function onSubmit (e: React.FormEvent<HTMLFormElement>) {
|
|
17
|
-
try {
|
|
18
|
-
const data = await request('post /api/signup', state, e, isLoading, setState)
|
|
19
|
-
setStore((prev) => ({ ...prev, ...data }))
|
|
20
|
-
setTimeout(() => navigate('/'), 0) // wait for setStore
|
|
21
|
-
} catch (e) {
|
|
22
|
-
setState((prev) => ({ ...prev, errors: e as Errors }))
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<div class="">
|
|
28
|
-
<Topbar title={<>Start your 21 day Free Trial</>} />
|
|
29
|
-
|
|
30
|
-
<form onSubmit={onSubmit}>
|
|
31
|
-
<div class="grid grid-cols-2 gap-6">
|
|
32
|
-
<div>
|
|
33
|
-
<label for="name">Your Name</label>
|
|
34
|
-
<Field name="name" placeholder="E.g. Bruce Wayne" state={state}
|
|
35
|
-
onChange={(e) => onChange(setState, e)}
|
|
36
|
-
errorTitle={/^(name|firstName|lastName)$/} // if different from `name`
|
|
37
|
-
/>
|
|
38
|
-
</div>
|
|
39
|
-
<div>
|
|
40
|
-
<label for="business.name">Company Name</label>
|
|
41
|
-
<Field name="business.name" placeholder="E.g. Wayne Enterprises" state={state} onChange={(e) => onChange(setState, e)} />
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
<div>
|
|
45
|
-
<label for="email">Email Address</label>
|
|
46
|
-
<Field name="email" type="email" state={state} onChange={(e) => onChange(setState, e)} placeholder="Your email address..." />
|
|
47
|
-
</div>
|
|
48
|
-
<div>
|
|
49
|
-
<label for="password">Password</label>
|
|
50
|
-
<Field name="password" type="password" state={state} onChange={(e) => onChange(setState, e)}/>
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
|
-
<div class="mb-14">
|
|
54
|
-
Already have an account? You can <Link to="/signin" class="underline2 is-active">sign in here</Link>.
|
|
55
|
-
<FormError state={state} className="pt-2" />
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
<Button class="w-full" isLoading={isLoading[0]} type="submit">Create Account</Button>
|
|
59
|
-
</form>
|
|
60
|
-
</div>
|
|
61
|
-
)
|
|
62
|
-
}
|
|
@@ -1,268 +0,0 @@
|
|
|
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
|
-
// }
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
`
|
|
@@ -1,102 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,98 +0,0 @@
|
|
|
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"> </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
|
-
}
|