@clubedge/create-clubedge-app 0.15.0 → 0.16.0
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/CHANGELOG.md +8 -0
- package/README.md +11 -10
- package/package.json +3 -3
- package/template/.env.example +6 -0
- package/template/README.md +10 -4
- package/template/SETUP.md +14 -3
- package/template/apps/start/.env.example +6 -0
- package/template/apps/start/AGENTS.md +1 -1
- package/template/apps/start/package.json +1 -1
- package/template/apps/start/src/components/auth/auth-form-controls.tsx +27 -8
- package/template/apps/start/src/components/dashboard/account-button.tsx +20 -14
- package/template/apps/start/src/components/error-reporting.tsx +49 -0
- package/template/apps/start/src/components/route-error.tsx +35 -0
- package/template/apps/start/src/components/variants/error-reporting.sentry.tsx +34 -0
- package/template/apps/start/src/env/auth.ts +9 -1
- package/template/apps/start/src/jobs/index.ts +24 -0
- package/template/apps/start/src/routes/__root.tsx +8 -0
- package/template/apps/start/src/routes/api/client-errors.ts +49 -0
- package/template/apps/start/src/routes/auth/change-password.ts +12 -0
- package/template/apps/start/src/routes/auth/delete-account.ts +12 -0
- package/template/apps/start/src/routes/auth/update-profile.ts +12 -0
- package/template/apps/start/src/routes/dashboard.tsx +25 -19
- package/template/apps/start/src/routes/index.tsx +29 -26
- package/template/apps/start/src/routes/login.tsx +1 -0
- package/template/apps/start/src/routes/settings.tsx +156 -0
- package/template/apps/start/src/server/auth-email.ts +13 -2
- package/template/apps/start/src/server/observability.ts +6 -1
- package/template/apps/start/src/server/payments.ts +6 -1
- package/template/apps/start/src/server/settings-forms.ts +110 -0
- package/template/apps/start/src/server/settings.functions.ts +24 -0
- package/template/apps/start/src/server/variants/observability.sentry.ts +17 -1
- package/template/apps/start/vite.config.ts +10 -2
- package/template/apps/web/next.config.ts +22 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/src/app/api/client-errors/route.ts +48 -0
- package/template/apps/web/src/app/dashboard/_components/account-button.tsx +20 -14
- package/template/apps/web/src/app/dashboard/page.tsx +25 -25
- package/template/apps/web/src/app/error.tsx +30 -0
- package/template/apps/web/src/app/layout.tsx +6 -0
- package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +27 -8
- package/template/apps/web/src/app/login/page.tsx +1 -0
- package/template/apps/web/src/app/page.tsx +29 -26
- package/template/apps/web/src/app/settings/actions.ts +102 -0
- package/template/apps/web/src/app/settings/page.tsx +160 -0
- package/template/apps/web/src/components/error-reporting.tsx +51 -0
- package/template/apps/web/src/components/variants/error-reporting.sentry.tsx +36 -0
- package/template/apps/web/src/env/auth.ts +9 -1
- package/template/apps/web/src/jobs/index.ts +24 -0
- package/template/apps/web/src/server/auth-email.ts +13 -2
- package/template/apps/web/src/server/observability.ts +6 -1
- package/template/apps/web/src/server/payments.ts +6 -1
- package/template/apps/web/src/server/variants/observability.sentry.ts +17 -1
- package/template/clubedge.template.json +32 -2
- package/template/e2e/auth.spec.ts +1 -1
- package/template/e2e/email-verification.spec.ts +3 -1
- package/template/e2e/observability.spec.ts +48 -22
- package/template/e2e/password-reset.spec.ts +1 -1
- package/template/e2e/settings.spec.ts +78 -0
- package/template/e2e/telemetry-fake.ts +7 -0
- package/template/package.json +1 -1
- package/template/packages/auth/package.json +1 -1
- package/template/packages/auth/src/types.ts +19 -0
- package/template/packages/auth-better-auth/package.json +1 -1
- package/template/packages/auth-better-auth/src/index.test.ts +80 -1
- package/template/packages/auth-better-auth/src/index.ts +38 -0
- package/template/packages/auth-supabase/package.json +1 -1
- package/template/packages/auth-supabase/src/index.test.ts +86 -2
- package/template/packages/auth-supabase/src/index.ts +42 -2
- package/template/packages/cache/package.json +1 -1
- package/template/packages/cache-redis/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/core/src/index.ts +1 -0
- package/template/packages/core/src/request-body.test.ts +36 -0
- package/template/packages/core/src/request-body.ts +29 -0
- package/template/packages/db/package.json +2 -1
- package/template/packages/db/src/index.ts +7 -1
- package/template/packages/db/src/users.test.ts +39 -0
- package/template/packages/db/src/users.ts +27 -0
- package/template/packages/email/package.json +1 -1
- package/template/packages/email-resend/package.json +1 -1
- package/template/packages/email-smtp/package.json +1 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs-pg-boss/package.json +1 -1
- package/template/packages/local-db/package.json +1 -1
- package/template/packages/observability/package.json +1 -1
- package/template/packages/observability/src/index.test.ts +67 -0
- package/template/packages/observability/src/index.ts +56 -0
- package/template/packages/observability-otel/package.json +1 -1
- package/template/packages/observability-sentry/package.json +8 -3
- package/template/packages/observability-sentry/src/browser.ts +36 -0
- package/template/packages/observability-sentry/src/sourcemaps.cjs +52 -0
- package/template/packages/observability-sentry/src/sourcemaps.d.cts +10 -0
- package/template/packages/observability-sentry/src/sourcemaps.test.ts +56 -0
- package/template/packages/observability-sentry/src/vite.d.mts +3 -0
- package/template/packages/observability-sentry/src/vite.mjs +21 -0
- package/template/packages/payments/package.json +1 -1
- package/template/packages/payments/src/billing.test.ts +2 -1
- package/template/packages/payments/src/billing.ts +15 -10
- package/template/packages/payments/src/index.ts +1 -0
- package/template/packages/payments-stripe/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/storage-s3/package.json +1 -1
- package/template/packages/storage-supabase/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/playwright.config.ts +15 -2
- package/template/pnpm-lock.yaml +172 -0
- package/template.lock.json +29 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `create-clubedge-app` are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.16.0
|
|
6
|
+
|
|
7
|
+
- Default to Starter `v0.14.0`, which adds:
|
|
8
|
+
- An account settings page at `/settings` when an auth provider is selected: display name, password change (signing out other sessions), and account deletion. Supabase projects set `SUPABASE_SECRET_KEY` to allow deletion.
|
|
9
|
+
- Browser error reporting with observability on: uncaught errors, unhandled rejections, and render errors caught by the new error pages, through `/api/client-errors` (`otel`) or Sentry's browser SDK (`sentry`). Sentry builds with `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` upload source maps and then delete them.
|
|
10
|
+
- Auth emails sent through the background-jobs queue, with retries, when both email and jobs are selected.
|
|
11
|
+
- None of this needs CLI code: it all comes from the Starter's manifest.
|
|
12
|
+
|
|
5
13
|
## 0.15.0
|
|
6
14
|
|
|
7
15
|
- Add `status`: a project's Starter release, framework, modules, and Git state, whether a newer Starter release exists (from npm, still working offline), and the command to run next.
|
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
|
|
|
127
127
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref main
|
|
128
128
|
|
|
129
129
|
# Reproduce a specific Starter release or commit
|
|
130
|
-
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.
|
|
130
|
+
pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.14.0
|
|
131
131
|
pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
|
|
132
132
|
|
|
133
133
|
# Test local Starter changes before releasing them
|
|
@@ -146,7 +146,7 @@ pnpm clubedge add email resend payments stripe # several modules as
|
|
|
146
146
|
pnpm clubedge remove storage payments
|
|
147
147
|
pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
|
|
148
148
|
pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
|
|
149
|
-
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.
|
|
149
|
+
pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.14.0 # or a specific tag, branch, or commit
|
|
150
150
|
```
|
|
151
151
|
|
|
152
152
|
The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
|
|
@@ -177,27 +177,27 @@ pnpm clubedge doctor # Node.js, pnpm, dependencies, lockfile, env file, and p
|
|
|
177
177
|
Each CLI release is tested against one specific Starter release:
|
|
178
178
|
|
|
179
179
|
```text
|
|
180
|
-
create-clubedge-app v0.
|
|
180
|
+
create-clubedge-app v0.16.0
|
|
181
181
|
|
|
|
182
182
|
v
|
|
183
|
-
Clubedge Starter v0.
|
|
184
|
-
commit
|
|
183
|
+
Clubedge Starter v0.14.0
|
|
184
|
+
commit 2219a21c2eb237b7d8ee004ea8bbeb098b70106e
|
|
185
185
|
```
|
|
186
186
|
|
|
187
187
|
The current relationship is:
|
|
188
188
|
|
|
189
189
|
| CLI release | Default Starter release | Meaning |
|
|
190
190
|
| --- | --- | --- |
|
|
191
|
-
| `create-clubedge-app@0.
|
|
191
|
+
| `create-clubedge-app@0.16.0` | `clubedge-starter@v0.14.0` | The CLI's required, tested default |
|
|
192
192
|
|
|
193
193
|
This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
|
|
194
194
|
|
|
195
|
-
The `v0.
|
|
196
|
-
`
|
|
195
|
+
The `v0.14.0` tag is expected to resolve to commit
|
|
196
|
+
`2219a21c2eb237b7d8ee004ea8bbeb098b70106e`. The CLI verifies this before
|
|
197
197
|
scaffolding with the default reference and fails rather than silently using a
|
|
198
198
|
moved tag.
|
|
199
199
|
|
|
200
|
-
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.
|
|
200
|
+
When the Starter changes, its maintainers first publish a new versioned release, such as `v0.14.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
|
|
201
201
|
|
|
202
202
|
Every generated project records its CLI version, Starter repository, Starter ref, Starter commit, framework, and modules in `package.json` and `README.md`.
|
|
203
203
|
|
|
@@ -252,7 +252,7 @@ CI packs the CLI with `npm pack`, scaffolds clean projects from the packed tarba
|
|
|
252
252
|
1. Make and validate changes in `clubedge-starter`.
|
|
253
253
|
2. Run lint, typecheck, unit tests, browser tests, and production build.
|
|
254
254
|
3. Update the Starter package versions.
|
|
255
|
-
4. Commit `main`, create a release tag such as `v0.
|
|
255
|
+
4. Commit `main`, create a release tag such as `v0.14.0`, and publish the Starter release.
|
|
256
256
|
|
|
257
257
|
### CLI maintainers
|
|
258
258
|
|
|
@@ -277,6 +277,7 @@ Each phase ends with a Starter release and a CLI release pinned to it. The full
|
|
|
277
277
|
| 0.13.0 | v0.12.0 | Background jobs |
|
|
278
278
|
| 0.14.0 | v0.13.0 | Observability |
|
|
279
279
|
| 0.15.0 | v0.13.1 | `status` and `doctor`, several modules in one change, conflict and upgrade summaries, macOS CI |
|
|
280
|
+
| 0.16.0 | v0.14.0 | Account settings page, browser error reporting and Sentry source maps, auth emails through the jobs queue |
|
|
280
281
|
| next | | Show the Starter's version of a conflicted file next to yours |
|
|
281
282
|
|
|
282
283
|
## Community
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clubedge/create-clubedge-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.9.0",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"clubedge": {
|
|
24
24
|
"starterRepository": "Clubedge/clubedge-starter",
|
|
25
|
-
"starterRef": "v0.
|
|
26
|
-
"starterCommit": "
|
|
25
|
+
"starterRef": "v0.14.0",
|
|
26
|
+
"starterCommit": "2219a21c2eb237b7d8ee004ea8bbeb098b70106e"
|
|
27
27
|
},
|
|
28
28
|
"license": "Apache-2.0",
|
|
29
29
|
"repository": {
|
package/template/.env.example
CHANGED
|
@@ -16,6 +16,8 @@ NEXT_PUBLIC_SUPABASE_URL= # clubedge:only infra!=supabase
|
|
|
16
16
|
# Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
|
|
17
17
|
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
|
|
18
18
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
|
19
|
+
# Optional, server-side only: lets users delete their account from the settings page.
|
|
20
|
+
SUPABASE_SECRET_KEY=
|
|
19
21
|
# clubedge:end
|
|
20
22
|
|
|
21
23
|
# clubedge:if auth=better-auth
|
|
@@ -82,6 +84,10 @@ OTEL_SERVICE_NAME=
|
|
|
82
84
|
SENTRY_DSN=
|
|
83
85
|
SENTRY_ENVIRONMENT=
|
|
84
86
|
SENTRY_TRACES_SAMPLE_RATE=
|
|
87
|
+
# Build time only: with an auth token, the build uploads browser source maps to this project.
|
|
88
|
+
# SENTRY_AUTH_TOKEN=
|
|
89
|
+
# SENTRY_ORG=
|
|
90
|
+
# SENTRY_PROJECT=
|
|
85
91
|
# clubedge:end
|
|
86
92
|
|
|
87
93
|
# clubedge:if storage=s3
|
package/template/README.md
CHANGED
|
@@ -57,6 +57,7 @@ Every combination in the CI matrix is generated as a real project and runs its f
|
|
|
57
57
|
- Better Auth with users and sessions in your own PostgreSQL, secure session cookies, a protected dashboard, and rate-limited sign-in. <!-- clubedge:only auth=better-auth -->
|
|
58
58
|
- Password reset by email, and email verification that can be required before sign-in. <!-- clubedge:only auth=better-auth && email!=none -->
|
|
59
59
|
- Password reset by email, sent by Supabase. <!-- clubedge:only auth=supabase -->
|
|
60
|
+
- An account settings page: display name, password change, and account deletion. <!-- clubedge:only auth!=none -->
|
|
60
61
|
- Email over SMTP, for any provider or a local inbox; development prints emails to the server log until SMTP is configured. <!-- clubedge:only email=smtp -->
|
|
61
62
|
- Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
|
|
62
63
|
- S3-compatible storage adapter for AWS S3, Cloudflare R2, and MinIO. <!-- clubedge:only storage=s3 -->
|
|
@@ -276,13 +277,18 @@ Local services:
|
|
|
276
277
|
- `pnpm infra:up`: run only the local database, for example for `pnpm check:services` or browser tests. <!-- clubedge:only infra=local -->
|
|
277
278
|
- `pnpm infra:reset`: delete the local database and files in `.data/` (stop `pnpm dev` first). <!-- clubedge:only infra=local -->
|
|
278
279
|
|
|
280
|
+
Check the project:
|
|
281
|
+
|
|
282
|
+
- `pnpm clubedge status`: the project's Starter release, modules, and Git state, whether a newer Starter release exists, and the command to run next.
|
|
283
|
+
- `pnpm clubedge doctor`: check Node.js, pnpm, the dependencies, the lockfile, and `apps/web/.env.local` against the example, then run `pnpm check:services`. It changes nothing and exits with an error when a check fails.
|
|
284
|
+
|
|
279
285
|
Change the project later:
|
|
280
286
|
|
|
281
|
-
- `pnpm clubedge add <module> <option>`: add a module or switch its option, for example `pnpm clubedge add auth better-auth` or `pnpm clubedge add infra local`. Run `pnpm clubedge add` without arguments to choose from a list.
|
|
282
|
-
- `pnpm clubedge remove <module>`: remove
|
|
283
|
-
- `pnpm clubedge upgrade`: move the project to the newest Starter release.
|
|
287
|
+
- `pnpm clubedge add <module> <option>`: add a module or switch its option, for example `pnpm clubedge add auth better-auth` or `pnpm clubedge add infra local`. Name several to change them together, as in `pnpm clubedge add email resend payments stripe`. Run `pnpm clubedge add` without arguments to choose from a list.
|
|
288
|
+
- `pnpm clubedge remove <module>`: remove one or more modules, for example `pnpm clubedge remove storage`.
|
|
289
|
+
- `pnpm clubedge upgrade`: move the project to the newest Starter release. It first lists the releases in between.
|
|
284
290
|
|
|
285
|
-
These commands merge the change into your code instead of overwriting it: files you never touched are updated, your edits are kept, and lines you both changed get conflict markers. Commit first, because they need a clean Git tree so that `git diff` shows the result. Add `--dry-run` to preview a change.
|
|
291
|
+
These commands merge the change into your code instead of overwriting it: files you never touched are updated, your edits are kept, and lines you both changed get conflict markers, listed per file afterward. Commit first, because they need a clean Git tree so that `git diff` shows the result. Add `--dry-run` to preview a change.
|
|
286
292
|
|
|
287
293
|
## Documentation
|
|
288
294
|
|
package/template/SETUP.md
CHANGED
|
@@ -66,7 +66,8 @@ The app is available at <http://localhost:3000>, the liveness endpoint at <http:
|
|
|
66
66
|
|
|
67
67
|
## Supabase Auth
|
|
68
68
|
|
|
69
|
-
Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase; never
|
|
69
|
+
Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase, and the publishable key is all sign-in needs. To let users delete their account from the settings page, also set `SUPABASE_SECRET_KEY` to a secret key from the same settings; it stays on the server, so never give it a `NEXT_PUBLIC_` prefix. <!-- clubedge:only framework=next -->
|
|
70
|
+
Create or select a Supabase project and copy its URL and publishable key from the project's API settings into the Supabase variables above. Authentication runs on the server, which validates every user with Supabase, and the publishable key is all sign-in needs. To let users delete their account from the settings page, also set `SUPABASE_SECRET_KEY` to a secret key from the same settings; it stays on the server. <!-- clubedge:only framework=tanstack-start -->
|
|
70
71
|
|
|
71
72
|
In Supabase Auth URL configuration, add this redirect URL for local email confirmation and password reset links:
|
|
72
73
|
|
|
@@ -96,6 +97,8 @@ Better Auth sends its emails through the [email module](#email): <!-- clubedge:o
|
|
|
96
97
|
- **Password reset:** "Forgot password?" on the login page emails a link to `/reset-password`, valid for one hour and usable once. <!-- clubedge:only email!=none -->
|
|
97
98
|
- **Email verification:** every new account gets a confirmation link. Sign-up signs the user in right away unless `BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=true`, which refuses sign-in until the link is opened. <!-- clubedge:only email!=none -->
|
|
98
99
|
|
|
100
|
+
These emails are sent by the `send-email` [background job](#background-jobs), so sign-up never waits for the email provider, and a failed send is retried with backoff. <!-- clubedge:only email!=none && jobs!=none -->
|
|
101
|
+
|
|
99
102
|
<!-- clubedge:end -->
|
|
100
103
|
|
|
101
104
|
## Local services
|
|
@@ -260,7 +263,8 @@ export const welcomeEmail = defineJob<{ userId: string }>({
|
|
|
260
263
|
});
|
|
261
264
|
```
|
|
262
265
|
|
|
263
|
-
Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own.
|
|
266
|
+
Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. To send any email in the background, enqueue `sendEmailJob` from `@/jobs` with the message, as the auth emails do. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own. <!-- clubedge:only email!=none -->
|
|
267
|
+
Queue it from server code with `getJobs().enqueue(welcomeEmail, { userId })`, from `@/server/jobs`. A thrown error fails the attempt and the job is retried (`retryLimit`, `retryDelaySeconds`, `retryBackoff`). An attempt that runs past `timeoutSeconds`, or whose server stops, is retried too, so make jobs safe to run twice. Add `schedule: "0 3 * * *"` (cron syntax) to run a job on a schedule. Restart `pnpm dev` after adding or changing a job. The dashboard's jobs card queues the sample job from `apps/web/src/jobs/sample.ts` and lists its recent runs; remove both when you add your own. <!-- clubedge:only email=none -->
|
|
264
268
|
|
|
265
269
|
Where no server keeps running, as on Vercel and Netlify, there is no worker: `JOBS_WORKER` defaults to `off` there, and you can set it yourself anywhere. Set `CRON_SECRET` to 16 or more random characters and call `GET /api/jobs/run` every minute or so with the header `Authorization: Bearer <CRON_SECRET>`, from Vercel Cron, a Netlify scheduled function, or any scheduler. Each call runs the jobs that are due. Scheduled jobs need a worker.
|
|
266
270
|
|
|
@@ -291,10 +295,14 @@ docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:latest
|
|
|
291
295
|
Create a Node.js project in [Sentry](https://sentry.io) and set `SENTRY_DSN` to its DSN. `SENTRY_TRACES_SAMPLE_RATE` is the share of requests traced, from 0 to 1 (default 0.1; use 1 while trying it out), and `SENTRY_ENVIRONMENT` labels events (default `NODE_ENV`). The SDK sends no user details, cookies, query strings, or request bodies. The spans Next.js and pg-boss create join the same traces. <!-- clubedge:only framework=next -->
|
|
292
296
|
Create a Node.js project in [Sentry](https://sentry.io) and set `SENTRY_DSN` to its DSN. `SENTRY_TRACES_SAMPLE_RATE` is the share of requests traced, from 0 to 1 (default 0.1; use 1 while trying it out), and `SENTRY_ENVIRONMENT` labels events (default `NODE_ENV`). The SDK sends no user details, cookies, query strings, or request bodies. <!-- clubedge:only framework=tanstack-start -->
|
|
293
297
|
|
|
298
|
+
To see your own code in browser stack traces, give the build `SENTRY_AUTH_TOKEN` (an auth token with the `project:releases` scope), `SENTRY_ORG`, and `SENTRY_PROJECT`. The build then makes source maps for the browser bundle, uploads them to Sentry, and deletes them, so they are never served. Without the token the build makes no source maps. Set the three variables where the build runs: in CI, or as build-time variables on your platform.
|
|
299
|
+
|
|
294
300
|
<!-- clubedge:end -->
|
|
295
301
|
<!-- clubedge:if observability!=none -->
|
|
296
302
|
|
|
297
|
-
|
|
303
|
+
Uncaught errors in the browser are reported too, with the page's path and no query string. With OpenTelemetry, the browser sends them to `/api/client-errors`, which reports them like server errors and accepts 30 reports a minute per address. With Sentry, Sentry's browser SDK sends them directly; it is downloaded only when `SENTRY_DSN` is set. In both cases the browser reads its settings from `/api/client-errors` at runtime, so builds never bake them in.
|
|
304
|
+
|
|
305
|
+
On Vercel and Netlify, a function can stop right after it responds, so spans still waiting in a batch at that moment can be lost.
|
|
298
306
|
|
|
299
307
|
Observability goes through the provider-neutral `ObservabilityProvider` interface in `packages/observability` and the telemetry hooks in `@clubedge/core`. To use another tool, implement that interface in a new package and install it from `apps/web/src/server/observability.ts`; error reports and spans across the app stay the same.
|
|
300
308
|
|
|
@@ -433,6 +441,7 @@ Production needs these variables:
|
|
|
433
441
|
- `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=next && auth=supabase -->
|
|
434
442
|
- `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY`, from the Supabase project. <!-- clubedge:only framework=tanstack-start && auth=supabase -->
|
|
435
443
|
- `BETTER_AUTH_SECRET`, at least 32 random characters, different from development. <!-- clubedge:only auth=better-auth -->
|
|
444
|
+
- `SUPABASE_SECRET_KEY`, if users should be able to delete their account. <!-- clubedge:only auth=supabase -->
|
|
436
445
|
- `SMTP_URL` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=smtp -->
|
|
437
446
|
- `RESEND_API_KEY` and `EMAIL_FROM`; without them, production refuses to send email. <!-- clubedge:only email=resend -->
|
|
438
447
|
- `STRIPE_SECRET_KEY` (live mode), `STRIPE_WEBHOOK_SECRET` from the production webhook endpoint, and `STRIPE_PRICE_<PLAN>` for each plan. <!-- clubedge:only payments=stripe -->
|
|
@@ -445,6 +454,8 @@ Production needs these variables:
|
|
|
445
454
|
|
|
446
455
|
## Checks
|
|
447
456
|
|
|
457
|
+
`pnpm clubedge doctor` checks that the project can run: Node.js and pnpm versions, installed dependencies, the lockfile, and `apps/web/.env.local` against the example, followed by `pnpm check:services`. Start there when something does not work.
|
|
458
|
+
|
|
448
459
|
Run the checks relevant to your change before opening a pull request:
|
|
449
460
|
|
|
450
461
|
```sh
|
|
@@ -18,6 +18,8 @@ SUPABASE_URL= # clubedge:only infra!=supabase
|
|
|
18
18
|
# Local Supabase Auth. pnpm infra:status prints the publishable key to copy below. # clubedge:only infra=supabase
|
|
19
19
|
SUPABASE_URL=http://127.0.0.1:54321 # clubedge:only infra=supabase
|
|
20
20
|
SUPABASE_PUBLISHABLE_KEY=
|
|
21
|
+
# Optional: lets users delete their account from the settings page.
|
|
22
|
+
SUPABASE_SECRET_KEY=
|
|
21
23
|
# clubedge:end
|
|
22
24
|
|
|
23
25
|
# clubedge:if auth=better-auth
|
|
@@ -84,6 +86,10 @@ OTEL_SERVICE_NAME=
|
|
|
84
86
|
SENTRY_DSN=
|
|
85
87
|
SENTRY_ENVIRONMENT=
|
|
86
88
|
SENTRY_TRACES_SAMPLE_RATE=
|
|
89
|
+
# Build time only: with an auth token, the build uploads browser source maps to this project.
|
|
90
|
+
# SENTRY_AUTH_TOKEN=
|
|
91
|
+
# SENTRY_ORG=
|
|
92
|
+
# SENTRY_PROJECT=
|
|
87
93
|
# clubedge:end
|
|
88
94
|
|
|
89
95
|
# clubedge:if storage=s3
|
|
@@ -5,7 +5,7 @@ This app mirrors `apps/web` with TanStack Start. Follow the root `AGENTS.md`, pl
|
|
|
5
5
|
- Read the skills bundled with the installed packages before changing framework code: `node_modules/@tanstack/react-start/skills/` and `node_modules/@tanstack/start-client-core/skills/`. They match the installed version; APIs change often.
|
|
6
6
|
- Route loaders run on the server and in the browser. Keep database, provider, and secret access in `src/server/`, reached from loaders through `createServerFn` (see `src/server/auth.functions.ts`).
|
|
7
7
|
- Every server function and server route that touches private data must check the user itself. A route guard only protects the page, not the endpoint.
|
|
8
|
-
- Modules in `src/server/` start with `import "@tanstack/react-start/server-only";` so they can never reach the browser bundle.
|
|
8
|
+
- Modules in `src/server/` start with `import "@tanstack/react-start/server-only";` so they can never reach the browser bundle. The exception is `*.functions.ts`: routes import their `createServerFn` functions, and the Start compiler replaces each handler with a call to the server in the browser build. Keep them thin, importing the server-only modules they call.
|
|
9
9
|
- Forms post to server routes that answer with 303 redirects (`src/server/auth-forms.ts`), so they work before hydration. Do not throw a router `redirect` from a POST handler; it uses 307 and repeats the POST.
|
|
10
10
|
- Search params are plain strings (`src/router.tsx`). Build login links with `loginUrl()` so they match the Next.js app.
|
|
11
11
|
- `src/routeTree.gen.ts` is generated by `vite dev` and `vite build`. Commit it, but do not edit it by hand.
|
|
@@ -30,25 +30,31 @@ export function AuthForm({ action, children }: { action: string; children: React
|
|
|
30
30
|
export function PasswordField({
|
|
31
31
|
isSignUp,
|
|
32
32
|
label = "Password",
|
|
33
|
+
name = "password",
|
|
34
|
+
minLength = 8,
|
|
33
35
|
}: {
|
|
34
36
|
isSignUp: boolean;
|
|
35
37
|
label?: string;
|
|
38
|
+
/** The field's name and id; settings forms have more than one password field. */
|
|
39
|
+
name?: string;
|
|
40
|
+
/** 1 for fields that confirm an existing password, which may predate the current rules. */
|
|
41
|
+
minLength?: number;
|
|
36
42
|
}) {
|
|
37
43
|
const [visible, setVisible] = useState(false);
|
|
38
44
|
const pending = useContext(PendingContext);
|
|
39
45
|
|
|
40
46
|
return (
|
|
41
47
|
<div className="grid gap-2">
|
|
42
|
-
<Label htmlFor=
|
|
48
|
+
<Label htmlFor={name}>{label}</Label>
|
|
43
49
|
<div className="relative">
|
|
44
50
|
<Input
|
|
45
|
-
aria-describedby={isSignUp ?
|
|
51
|
+
aria-describedby={isSignUp ? `${name}-hint` : undefined}
|
|
46
52
|
autoComplete={isSignUp ? "new-password" : "current-password"}
|
|
47
53
|
className="h-11 pr-11"
|
|
48
|
-
id=
|
|
54
|
+
id={name}
|
|
49
55
|
maxLength={128}
|
|
50
|
-
minLength={
|
|
51
|
-
name=
|
|
56
|
+
minLength={minLength}
|
|
57
|
+
name={name}
|
|
52
58
|
placeholder={isSignUp ? "Create a password" : "Enter your password"}
|
|
53
59
|
// readOnly rather than disabled: disabled fields are left out of the submitted form.
|
|
54
60
|
readOnly={pending}
|
|
@@ -70,7 +76,7 @@ export function PasswordField({
|
|
|
70
76
|
</button>
|
|
71
77
|
</div>
|
|
72
78
|
{isSignUp && (
|
|
73
|
-
<p className="text-xs text-muted-foreground" id=
|
|
79
|
+
<p className="text-xs text-muted-foreground" id={`${name}-hint`}>
|
|
74
80
|
Use at least 8 characters.
|
|
75
81
|
</p>
|
|
76
82
|
)}
|
|
@@ -99,11 +105,24 @@ export function EmailField() {
|
|
|
99
105
|
);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
export function SubmitButton({
|
|
108
|
+
export function SubmitButton({
|
|
109
|
+
children,
|
|
110
|
+
disabled,
|
|
111
|
+
variant,
|
|
112
|
+
}: {
|
|
113
|
+
children: ReactNode;
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
variant?: "default" | "destructive";
|
|
116
|
+
}) {
|
|
103
117
|
const pending = useContext(PendingContext);
|
|
104
118
|
|
|
105
119
|
return (
|
|
106
|
-
<Button
|
|
120
|
+
<Button
|
|
121
|
+
className="h-11 w-full justify-center"
|
|
122
|
+
disabled={disabled || pending}
|
|
123
|
+
type="submit"
|
|
124
|
+
variant={variant}
|
|
125
|
+
>
|
|
107
126
|
{pending ? (
|
|
108
127
|
<Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" />
|
|
109
128
|
) : null}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Link } from "@tanstack/react-router";
|
|
2
2
|
|
|
3
3
|
import type { AuthUser } from "@/server/auth";
|
|
4
|
-
import { Button } from "@clubedge/ui/components/button";
|
|
4
|
+
import { Button, buttonVariants } from "@clubedge/ui/components/button";
|
|
5
|
+
import { cn } from "@clubedge/ui/lib/utils";
|
|
5
6
|
|
|
6
7
|
type AccountButtonProps = {
|
|
7
8
|
user: AuthUser | null;
|
|
@@ -11,23 +12,28 @@ type AccountButtonProps = {
|
|
|
11
12
|
export function AccountButton({ user, className }: AccountButtonProps) {
|
|
12
13
|
if (!user) {
|
|
13
14
|
return (
|
|
14
|
-
<
|
|
15
|
+
<Link to="/login" className={buttonVariants({ className, size: "sm", variant: "outline" })}>
|
|
15
16
|
Sign in
|
|
16
|
-
</
|
|
17
|
+
</Link>
|
|
17
18
|
);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
return (
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
<div className={cn("flex items-center gap-2", className)}>
|
|
23
|
+
<Link to="/settings" className={buttonVariants({ size: "sm", variant: "ghost" })}>
|
|
24
|
+
Settings
|
|
25
|
+
</Link>
|
|
26
|
+
<form action="/auth/sign-out" className="flex-1" method="post">
|
|
27
|
+
<Button
|
|
28
|
+
className="w-full"
|
|
29
|
+
size="sm"
|
|
30
|
+
title={user.email ?? undefined}
|
|
31
|
+
type="submit"
|
|
32
|
+
variant="outline"
|
|
33
|
+
>
|
|
34
|
+
Sign out
|
|
35
|
+
</Button>
|
|
36
|
+
</form>
|
|
37
|
+
</div>
|
|
32
38
|
);
|
|
33
39
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
const MAX_REPORTS = 10;
|
|
4
|
+
let sent = 0;
|
|
5
|
+
|
|
6
|
+
type Kind = "error" | "unhandledrejection" | "render";
|
|
7
|
+
|
|
8
|
+
function describe(reason: unknown) {
|
|
9
|
+
if (reason instanceof Error)
|
|
10
|
+
return { name: reason.name, message: reason.message, stack: reason.stack };
|
|
11
|
+
return { name: "Error", message: String(reason) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function send(kind: Kind, reason: unknown) {
|
|
15
|
+
if (sent >= MAX_REPORTS) return;
|
|
16
|
+
sent++;
|
|
17
|
+
const body = JSON.stringify({ kind, ...describe(reason), path: window.location.pathname });
|
|
18
|
+
// keepalive lets the report finish even if the page is being closed.
|
|
19
|
+
fetch("/api/client-errors", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
body,
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
keepalive: true,
|
|
24
|
+
}).catch(() => {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reports a render error that an error boundary caught, so it never reaches the window. */
|
|
28
|
+
export function reportBrowserError(error: unknown) {
|
|
29
|
+
send("render", error);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sends uncaught errors and unhandled promise rejections from the browser to /api/client-errors,
|
|
34
|
+
* which reports them like server errors. At most a few per page load.
|
|
35
|
+
*/
|
|
36
|
+
export function ErrorReporting() {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const onError = (event: ErrorEvent) => send("error", event.error ?? event.message);
|
|
39
|
+
const onRejection = (event: PromiseRejectionEvent) => send("unhandledrejection", event.reason);
|
|
40
|
+
// Capture phase: runs before handlers that might stop the event, such as a dev overlay.
|
|
41
|
+
window.addEventListener("error", onError, true);
|
|
42
|
+
window.addEventListener("unhandledrejection", onRejection, true);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener("error", onError, true);
|
|
45
|
+
window.removeEventListener("unhandledrejection", onRejection, true);
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// clubedge:if observability!=none
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
// clubedge:end
|
|
4
|
+
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
|
|
5
|
+
import { Button } from "@clubedge/ui/components/button";
|
|
6
|
+
// clubedge:if observability!=none
|
|
7
|
+
import { reportBrowserError } from "@/components/error-reporting";
|
|
8
|
+
// clubedge:end
|
|
9
|
+
|
|
10
|
+
/** Shown in place of a page that failed to render. */
|
|
11
|
+
export function RouteError(props: ErrorComponentProps) {
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
// clubedge:if observability!=none
|
|
14
|
+
// Error boundaries stop render errors before they reach the window, so report them here.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
reportBrowserError(props.error);
|
|
17
|
+
}, [props.error]);
|
|
18
|
+
// clubedge:end
|
|
19
|
+
return (
|
|
20
|
+
<main className="flex min-h-svh flex-col items-center justify-center gap-4 bg-background px-4 text-center">
|
|
21
|
+
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
|
22
|
+
<p className="max-w-sm text-sm text-muted-foreground">
|
|
23
|
+
This page could not be shown. Try again, or come back in a moment.
|
|
24
|
+
</p>
|
|
25
|
+
<Button
|
|
26
|
+
onClick={() => {
|
|
27
|
+
props.reset();
|
|
28
|
+
void router.invalidate();
|
|
29
|
+
}}
|
|
30
|
+
>
|
|
31
|
+
Try again
|
|
32
|
+
</Button>
|
|
33
|
+
</main>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { BrowserErrorReporting } from "@clubedge/observability";
|
|
3
|
+
|
|
4
|
+
type Capture = (error: unknown) => void;
|
|
5
|
+
let reporting: Promise<Capture | null> | undefined;
|
|
6
|
+
|
|
7
|
+
// The settings come from /api/client-errors at runtime, and Sentry's browser SDK is only
|
|
8
|
+
// downloaded when a DSN is configured.
|
|
9
|
+
function startReporting() {
|
|
10
|
+
reporting ??= fetch("/api/client-errors")
|
|
11
|
+
.then((response) => (response.ok ? (response.json() as Promise<BrowserErrorReporting>) : null))
|
|
12
|
+
.then(async (config) => {
|
|
13
|
+
if (config?.provider !== "sentry") return null;
|
|
14
|
+
const { captureBrowserError, startBrowserErrorReporting } =
|
|
15
|
+
await import("@clubedge/observability-sentry/browser");
|
|
16
|
+
startBrowserErrorReporting(config);
|
|
17
|
+
return captureBrowserError;
|
|
18
|
+
})
|
|
19
|
+
.catch(() => null);
|
|
20
|
+
return reporting;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reports a render error that an error boundary caught, so it never reaches the window. */
|
|
24
|
+
export function reportBrowserError(error: unknown) {
|
|
25
|
+
void startReporting().then((capture) => capture?.(error));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reports uncaught browser errors to Sentry. */
|
|
29
|
+
export function ErrorReporting() {
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
void startReporting();
|
|
32
|
+
}, []);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
@@ -10,11 +10,14 @@ import { getAppUrl } from "./server";
|
|
|
10
10
|
const supabaseAuthSchema = z.object({
|
|
11
11
|
SUPABASE_URL: optional(z.url()),
|
|
12
12
|
SUPABASE_PUBLISHABLE_KEY: optional(z.string()),
|
|
13
|
+
// Lets users delete their account from the settings page.
|
|
14
|
+
SUPABASE_SECRET_KEY: optional(z.string()),
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
export interface SupabaseAuthEnv {
|
|
16
18
|
url: string;
|
|
17
19
|
publishableKey: string;
|
|
20
|
+
secretKey?: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** Supabase Auth settings, or null while they are not configured. */
|
|
@@ -22,7 +25,12 @@ export function getSupabaseAuthEnv(): SupabaseAuthEnv | null {
|
|
|
22
25
|
const env = parseEnv(supabaseAuthSchema, "Supabase Auth");
|
|
23
26
|
const url = env.SUPABASE_URL;
|
|
24
27
|
const publishableKey = env.SUPABASE_PUBLISHABLE_KEY;
|
|
25
|
-
|
|
28
|
+
if (!url || !publishableKey) return null;
|
|
29
|
+
return {
|
|
30
|
+
url,
|
|
31
|
+
publishableKey,
|
|
32
|
+
...(env.SUPABASE_SECRET_KEY ? { secretKey: env.SUPABASE_SECRET_KEY } : {}),
|
|
33
|
+
};
|
|
26
34
|
}
|
|
27
35
|
// clubedge:end
|
|
28
36
|
|
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
import type { JobDefinition } from "@clubedge/jobs";
|
|
2
|
+
// clubedge:if email!=none
|
|
3
|
+
import type { EmailMessage } from "@clubedge/email";
|
|
4
|
+
import { defineJob } from "@clubedge/jobs";
|
|
5
|
+
import { email } from "@/server/email";
|
|
6
|
+
// clubedge:end
|
|
2
7
|
import { sampleJob } from "./sample";
|
|
3
8
|
|
|
4
9
|
export { sampleJob };
|
|
5
10
|
|
|
11
|
+
// clubedge:if email!=none
|
|
12
|
+
/**
|
|
13
|
+
* Sends an email in the background. Auth emails go through it, so a slow or failing email
|
|
14
|
+
* provider never delays sign-up, and failed sends are retried with backoff.
|
|
15
|
+
*/
|
|
16
|
+
export const sendEmailJob = defineJob<EmailMessage>({
|
|
17
|
+
name: "send-email",
|
|
18
|
+
retryLimit: 5,
|
|
19
|
+
retryDelaySeconds: 30,
|
|
20
|
+
retryBackoff: true,
|
|
21
|
+
async run(message) {
|
|
22
|
+
await email.send(message);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// clubedge:end
|
|
6
27
|
/** Every job the worker runs. Define a job in this folder, then add it here. */
|
|
7
28
|
export const jobs: JobDefinition[] = [sampleJob];
|
|
29
|
+
// clubedge:if email!=none
|
|
30
|
+
jobs.push(sendEmailJob);
|
|
31
|
+
// clubedge:end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
|
|
3
3
|
import { TooltipProvider } from "@clubedge/ui/components/tooltip";
|
|
4
|
+
// clubedge:if observability!=none
|
|
5
|
+
import { ErrorReporting } from "@/components/error-reporting";
|
|
6
|
+
// clubedge:end
|
|
7
|
+
import { RouteError } from "@/components/route-error";
|
|
4
8
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
|
5
9
|
import { siteConfig } from "@/config/site";
|
|
6
10
|
import appCss from "../styles.css?url";
|
|
@@ -32,6 +36,7 @@ export const Route = createRootRoute({
|
|
|
32
36
|
}),
|
|
33
37
|
shellComponent: RootDocument,
|
|
34
38
|
component: RootComponent,
|
|
39
|
+
errorComponent: RouteError,
|
|
35
40
|
});
|
|
36
41
|
|
|
37
42
|
function RootComponent() {
|
|
@@ -39,6 +44,9 @@ function RootComponent() {
|
|
|
39
44
|
<ThemeProvider>
|
|
40
45
|
<TooltipProvider>
|
|
41
46
|
<Outlet />
|
|
47
|
+
{/* clubedge:if observability!=none */}
|
|
48
|
+
<ErrorReporting />
|
|
49
|
+
{/* clubedge:end */}
|
|
42
50
|
</TooltipProvider>
|
|
43
51
|
</ThemeProvider>
|
|
44
52
|
);
|