@scalemule/nextjs 0.1.1 → 0.1.3

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/dist/index.mjs CHANGED
@@ -2098,6 +2098,40 @@ var StorageService = class extends ServiceModule {
2098
2098
  return { data: null, error: { code: "upload_error", message, status: 0 } };
2099
2099
  }
2100
2100
  }
2101
+ /**
2102
+ * Upload a file as a private attachment (e.g. chat / DM attachment).
2103
+ *
2104
+ * Same browser→S3 pipeline as {@link upload}, but enforces:
2105
+ * - `is_public: false` — caller cannot opt out
2106
+ * - no client-side compression — preserve the original bytes
2107
+ * - fail-closed if storage returns `is_public: true` for any reason
2108
+ *
2109
+ * The shared primitive used by `@scalemule/chat`'s chat-attachment uploader
2110
+ * and by `@scalemule/nextjs`'s `useMedia()` when an app's media policy is
2111
+ * `fast_trusted`. Both packages call this method via `@scalemule/sdk` —
2112
+ * `@scalemule/nextjs` does not depend on `@scalemule/chat`.
2113
+ *
2114
+ * See ADR-2026-04-26 (realtime-chat media pipeline) and
2115
+ * docs/MEDIA-UPLOADS.md for the full pattern.
2116
+ */
2117
+ async uploadPrivate(file, options) {
2118
+ const result = await this.upload(file, {
2119
+ ...options,
2120
+ isPublic: false,
2121
+ skipCompression: true
2122
+ });
2123
+ if (result.data && result.data.is_public === true) {
2124
+ return {
2125
+ data: null,
2126
+ error: {
2127
+ code: "visibility_violation",
2128
+ message: "Storage returned is_public=true for an uploadPrivate() call. This usually indicates a server-side bug or an admin override; the caller asked for a private upload.",
2129
+ status: 0
2130
+ }
2131
+ };
2132
+ }
2133
+ return result;
2134
+ }
2101
2135
  // --------------------------------------------------------------------------
2102
2136
  // Direct Upload (3-step with retry + stall)
2103
2137
  // --------------------------------------------------------------------------
@@ -3470,8 +3504,13 @@ var RealtimeService = class extends ServiceModule {
3470
3504
  var DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
3471
3505
  var MIN_CHUNK_SIZE = 5 * 1024 * 1024;
3472
3506
  var VideoService = class extends ServiceModule {
3473
- constructor() {
3474
- super(...arguments);
3507
+ /**
3508
+ * @param storage Required for {@link uploadViaStorage}. Wired up by the
3509
+ * top-level {@link ScaleMule} constructor.
3510
+ */
3511
+ constructor(client, storage) {
3512
+ super(client);
3513
+ this.storage = storage;
3475
3514
  this.basePath = "/v1/videos";
3476
3515
  }
3477
3516
  // --------------------------------------------------------------------------
@@ -3545,6 +3584,115 @@ var VideoService = class extends ServiceModule {
3545
3584
  async get(videoId, options) {
3546
3585
  return super._get(`/${videoId}`, options);
3547
3586
  }
3587
+ /**
3588
+ * Register a video from a file already uploaded to scalemule-storage.
3589
+ *
3590
+ * The synchronous handshake for storage-uploaded videos. Verifies the
3591
+ * caller's app owns the storage `file_id` and reads scan status:
3592
+ * - clean → 201, video record created with status='uploading',
3593
+ * transcode worker advances on `scan.file.completed`
3594
+ * - pending → 202, video record created with status='pending_scan',
3595
+ * worker advances when scan completes
3596
+ * - threat → 409 (rejected; row not created)
3597
+ *
3598
+ * Idempotent: calling twice with the same file_id returns the existing
3599
+ * row's data (the underlying INSERT uses ON DUPLICATE KEY UPDATE).
3600
+ *
3601
+ * For storage-backed videos `video_id == file_id` — the response's
3602
+ * `video_id` and `file_id` are identical, and either can be used with
3603
+ * `getStreamUrl()` / `get()`.
3604
+ *
3605
+ * Most callers should use {@link uploadViaStorage} which composes
3606
+ * `storage.uploadPrivate()` + `register()` in a single call.
3607
+ */
3608
+ async register(request, options) {
3609
+ return this.post(
3610
+ "/register",
3611
+ {
3612
+ file_id: request.fileId,
3613
+ sm_user_id: request.userId
3614
+ },
3615
+ options
3616
+ );
3617
+ }
3618
+ /**
3619
+ * Upload a video to storage (browser → S3 direct, private, no
3620
+ * compression) and register it with the video service so transcoding
3621
+ * + HLS streaming work.
3622
+ *
3623
+ * The canonical chat-attachment / progressive-video upload primitive.
3624
+ * Same private-direct-to-S3 path as `storage.uploadPrivate()`, plus a
3625
+ * follow-up `video.register()` call so HLS playback URLs become live
3626
+ * once the transcoder finishes.
3627
+ *
3628
+ * The returned `hls_url_promise` resolves once the video's status
3629
+ * flips to `ready` (or after a 30s timeout — caller can still serve
3630
+ * `original_view_url` as the fallback). Phase 3 of the realtime-chat
3631
+ * media pipeline ADR replaces the poll with a realtime subscription.
3632
+ *
3633
+ * If `register()` fails after a successful storage upload, the file
3634
+ * is *not* lost: the returned `file_id` is still valid as a generic
3635
+ * storage file. SDK logs a warning and resolves `hls_url_promise`
3636
+ * to `null`.
3637
+ */
3638
+ async uploadViaStorage(file, uploadOptions, requestOptions) {
3639
+ const uploadResult = await this.storage.uploadPrivate(file, {
3640
+ filename: uploadOptions?.filename,
3641
+ metadata: uploadOptions?.metadata,
3642
+ onProgress: uploadOptions?.onProgress,
3643
+ signal: uploadOptions?.signal
3644
+ });
3645
+ if (uploadResult.error || !uploadResult.data) {
3646
+ return { data: null, error: uploadResult.error };
3647
+ }
3648
+ const fileInfo = uploadResult.data;
3649
+ const fileId = fileInfo.id;
3650
+ const originalViewUrl = fileInfo.url ?? null;
3651
+ const registerResult = await this.register({ fileId, userId: uploadOptions?.userId }, requestOptions);
3652
+ if (registerResult.error || !registerResult.data) {
3653
+ console.warn(
3654
+ "[scalemule-sdk] video.register() failed after storage upload; HLS variants unavailable.",
3655
+ registerResult.error
3656
+ );
3657
+ return {
3658
+ data: {
3659
+ file_id: fileId,
3660
+ video_id: fileId,
3661
+ original_view_url: originalViewUrl,
3662
+ hls_url_promise: Promise.resolve(null)
3663
+ },
3664
+ error: null
3665
+ };
3666
+ }
3667
+ const videoId = registerResult.data.video_id;
3668
+ const hlsUrlPromise = this.pollTranscodeComplete(videoId, requestOptions);
3669
+ return {
3670
+ data: {
3671
+ file_id: fileId,
3672
+ video_id: videoId,
3673
+ original_view_url: originalViewUrl,
3674
+ hls_url_promise: hlsUrlPromise
3675
+ },
3676
+ error: null
3677
+ };
3678
+ }
3679
+ /**
3680
+ * Poll {@link get} until the video's status is `ready` or the 30s
3681
+ * timeout fires. Resolves to the HLS master playlist URL on success;
3682
+ * `null` on timeout (caller should fall back to `original_view_url`).
3683
+ */
3684
+ async pollTranscodeComplete(videoId, requestOptions) {
3685
+ const intervalMs = 1e3;
3686
+ const maxAttempts = 30;
3687
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3688
+ const result = await this.get(videoId, requestOptions);
3689
+ if (result.data?.status === "ready") {
3690
+ return `${this.client.getBaseUrl()}${this.basePath}/${videoId}/playlist.m3u8`;
3691
+ }
3692
+ await new Promise((r) => setTimeout(r, intervalMs));
3693
+ }
3694
+ return null;
3695
+ }
3548
3696
  /**
3549
3697
  * Get the HLS master playlist URL for streaming.
3550
3698
  * Returns the playlist URL that can be passed to a video player.
@@ -3787,6 +3935,18 @@ var ChatService = class extends ServiceModule {
3787
3935
  return this.post(`/messages/${messageId}/reactions`, data, options);
3788
3936
  }
3789
3937
  // --------------------------------------------------------------------------
3938
+ // Pins
3939
+ // --------------------------------------------------------------------------
3940
+ async pinMessage(messageId, options) {
3941
+ return this.post(`/messages/${messageId}/pin`, void 0, options);
3942
+ }
3943
+ async unpinMessage(messageId, options) {
3944
+ return this.del(`/messages/${messageId}/pin`, options);
3945
+ }
3946
+ async getPinnedMessages(conversationId, options) {
3947
+ return this._get(`/conversations/${conversationId}/pins`, options);
3948
+ }
3949
+ // --------------------------------------------------------------------------
3790
3950
  // Typing & Read Receipts
3791
3951
  // --------------------------------------------------------------------------
3792
3952
  async sendTyping(conversationId, options) {
@@ -3997,15 +4157,80 @@ var BillingService = class extends ServiceModule {
3997
4157
  );
3998
4158
  }
3999
4159
  // --------------------------------------------------------------------------
4000
- // Customers
4160
+ // Customers (legacy user-scoped — deprecated in favor of account-scoped)
4001
4161
  // --------------------------------------------------------------------------
4162
+ /** @deprecated Use `getAccountCustomer({ account_id })`. Targets the user-scoped legacy endpoint. */
4002
4163
  async createCustomer(data, options) {
4003
4164
  return this.post("/customers", data, options);
4004
4165
  }
4166
+ /** @deprecated Use `attachAccountPaymentMethod({ account_id }, ...)`. Targets the user-scoped legacy endpoint. */
4005
4167
  async addPaymentMethod(data, options) {
4006
4168
  return this.post("/payment-methods", data, options);
4007
4169
  }
4008
4170
  // --------------------------------------------------------------------------
4171
+ // Account-Scoped Billing (v2 — ADR-2026-04-25)
4172
+ //
4173
+ // The customer/payment-method/invoice record set belongs to an `app_account`
4174
+ // (typically a `customer_org`), not to a single user. These methods target
4175
+ // the `/v1/billing/accounts/{account_id}/*` endpoints described in
4176
+ // openapi/scalemule-money-billing-v1.yaml.
4177
+ // --------------------------------------------------------------------------
4178
+ accountBase(accountId) {
4179
+ return `/v1/billing/accounts/${encodeURIComponent(accountId)}`;
4180
+ }
4181
+ async getAccountCustomer(req, options) {
4182
+ const path = this.withQuery(`${this.accountBase(req.account_id)}/customer`, {
4183
+ billing_mode: req.billing_mode
4184
+ });
4185
+ return this.client.get(path, options);
4186
+ }
4187
+ async listAccountPaymentMethods(req, options) {
4188
+ const path = this.withQuery(`${this.accountBase(req.account_id)}/payment-methods`, {
4189
+ billing_mode: req.billing_mode
4190
+ });
4191
+ return this.client.get(path, options);
4192
+ }
4193
+ async attachAccountPaymentMethod(req, body, options) {
4194
+ const path = this.withQuery(`${this.accountBase(req.account_id)}/payment-methods`, {
4195
+ billing_mode: req.billing_mode
4196
+ });
4197
+ return this.client.post(path, body, options);
4198
+ }
4199
+ async detachAccountPaymentMethod(req, paymentMethodId, options) {
4200
+ const path = this.withQuery(
4201
+ `${this.accountBase(req.account_id)}/payment-methods/${encodeURIComponent(paymentMethodId)}`,
4202
+ { billing_mode: req.billing_mode }
4203
+ );
4204
+ return this.client.del(path, options);
4205
+ }
4206
+ async setDefaultAccountPaymentMethod(req, paymentMethodId, options) {
4207
+ const path = this.withQuery(
4208
+ `${this.accountBase(req.account_id)}/payment-methods/${encodeURIComponent(paymentMethodId)}/default`,
4209
+ { billing_mode: req.billing_mode }
4210
+ );
4211
+ return this.client.put(path, void 0, options);
4212
+ }
4213
+ async listAccountInvoices(req, params, options) {
4214
+ const path = this.withQuery(`${this.accountBase(req.account_id)}/invoices`, {
4215
+ billing_mode: req.billing_mode,
4216
+ limit: params?.limit,
4217
+ cursor: params?.cursor
4218
+ });
4219
+ return this.client.get(path, options);
4220
+ }
4221
+ async getAccountInvoice(req, invoiceId, options) {
4222
+ const path = this.withQuery(`${this.accountBase(req.account_id)}/invoices/${encodeURIComponent(invoiceId)}`, {
4223
+ billing_mode: req.billing_mode
4224
+ });
4225
+ return this.client.get(path, options);
4226
+ }
4227
+ async createAccountPortalSession(req, body, options) {
4228
+ const path = this.withQuery(`${this.accountBase(req.account_id)}/portal-session`, {
4229
+ billing_mode: req.billing_mode
4230
+ });
4231
+ return this.client.post(path, body ?? {}, options);
4232
+ }
4233
+ // --------------------------------------------------------------------------
4009
4234
  // Subscriptions
4010
4235
  // --------------------------------------------------------------------------
4011
4236
  async subscribe(data, options) {
@@ -5166,8 +5391,14 @@ var SearchService = class extends ServiceModule {
5166
5391
  };
5167
5392
  var PHOTO_BREAKPOINTS = [36, 150, 320, 640, 1080];
5168
5393
  var PhotoService = class extends ServiceModule {
5169
- constructor() {
5170
- super(...arguments);
5394
+ /**
5395
+ * @param storage Required for {@link uploadViaStorage}. Wired up by the
5396
+ * top-level {@link ScaleMule} constructor — most call sites should not
5397
+ * instantiate `PhotoService` directly.
5398
+ */
5399
+ constructor(client, storage) {
5400
+ super(client);
5401
+ this.storage = storage;
5171
5402
  this.basePath = "/v1/photos";
5172
5403
  }
5173
5404
  async upload(file, uploadOptions, requestOptions) {
@@ -5307,6 +5538,87 @@ var PhotoService = class extends ServiceModule {
5307
5538
  }
5308
5539
  };
5309
5540
  }
5541
+ /**
5542
+ * Upload a file to storage (browser → S3 direct, private, uncompressed) and
5543
+ * register it with the photo service so optimization + transform URLs work.
5544
+ *
5545
+ * The canonical chat-attachment / progressive-image upload primitive. Same
5546
+ * presigned-direct-to-S3 path as `storage.uploadPrivate()`, plus a follow-up
5547
+ * `photo.register()` call so `getTransformUrl()` / `getOptimalUrl()` return
5548
+ * usable URLs.
5549
+ *
5550
+ * The returned `optimized_url_promise` resolves once the photo's
5551
+ * `optimization_status` flips to `completed` (or once a 10s timeout fires;
5552
+ * the caller can still use `getTransformUrl()` directly — the on-demand
5553
+ * transform path produces a variant even before pre-rendered breakpoints
5554
+ * are cached). Phase 3 of ADR-2026-04-26 replaces the poll with a realtime
5555
+ * subscription.
5556
+ *
5557
+ * If `register()` fails after a successful storage upload (e.g. scan times
5558
+ * out), the file is *not* lost: the returned `file_id` is still valid as a
5559
+ * generic storage file. The SDK logs a warning and resolves
5560
+ * `optimized_url_promise` to `null`.
5561
+ */
5562
+ async uploadViaStorage(file, uploadOptions, requestOptions) {
5563
+ const uploadResult = await this.storage.uploadPrivate(file, {
5564
+ filename: uploadOptions?.filename,
5565
+ metadata: uploadOptions?.metadata,
5566
+ onProgress: uploadOptions?.onProgress,
5567
+ signal: uploadOptions?.signal
5568
+ });
5569
+ if (uploadResult.error || !uploadResult.data) {
5570
+ return { data: null, error: uploadResult.error };
5571
+ }
5572
+ const fileInfo = uploadResult.data;
5573
+ const fileId = fileInfo.id;
5574
+ const originalViewUrl = fileInfo.url ?? null;
5575
+ const registerResult = await this.register({ fileId, userId: uploadOptions?.userId }, requestOptions);
5576
+ if (registerResult.error || !registerResult.data) {
5577
+ console.warn(
5578
+ "[scalemule-sdk] photo.register() failed after storage upload; optimized variants unavailable.",
5579
+ registerResult.error
5580
+ );
5581
+ return {
5582
+ data: {
5583
+ file_id: fileId,
5584
+ photo_id: null,
5585
+ original_view_url: originalViewUrl,
5586
+ optimized_url_promise: Promise.resolve(null)
5587
+ },
5588
+ error: null
5589
+ };
5590
+ }
5591
+ const photoId = registerResult.data.id;
5592
+ const optimizedUrlPromise = this.pollOptimizationComplete(photoId, requestOptions);
5593
+ return {
5594
+ data: {
5595
+ file_id: fileId,
5596
+ photo_id: photoId,
5597
+ original_view_url: originalViewUrl,
5598
+ optimized_url_promise: optimizedUrlPromise
5599
+ },
5600
+ error: null
5601
+ };
5602
+ }
5603
+ /**
5604
+ * Poll {@link get} until the photo's `optimization_status` is `completed`
5605
+ * or the 10s timeout fires. Resolves to a usable transform URL on success;
5606
+ * `null` on timeout (caller should fall back to {@link getTransformUrl}
5607
+ * which uses the on-demand transform path).
5608
+ */
5609
+ async pollOptimizationComplete(photoId, requestOptions) {
5610
+ const intervalMs = 250;
5611
+ const maxAttempts = 40;
5612
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
5613
+ const result = await this.get(photoId, requestOptions);
5614
+ const status = result.data?.optimization_status;
5615
+ if (status === "completed") {
5616
+ return this.getOptimalUrl(photoId, 1080);
5617
+ }
5618
+ await new Promise((r) => setTimeout(r, intervalMs));
5619
+ }
5620
+ return null;
5621
+ }
5310
5622
  /** @deprecated Use upload() instead */
5311
5623
  async uploadPhoto(file, options) {
5312
5624
  return this.upload(file, options);
@@ -6111,7 +6423,7 @@ var ScaleMule = class {
6111
6423
  this.auth = new AuthService(this._client);
6112
6424
  this.storage = new StorageService(this._client);
6113
6425
  this.realtime = new RealtimeService(this._client);
6114
- this.video = new VideoService(this._client);
6426
+ this.video = new VideoService(this._client, this.storage);
6115
6427
  this.data = new DataService(this._client);
6116
6428
  this.chat = new ChatService(this._client);
6117
6429
  this.conference = new ConferenceService(this._client);
@@ -6138,7 +6450,7 @@ var ScaleMule = class {
6138
6450
  this.events = new EventsService(this._client);
6139
6451
  this.graph = new GraphService(this._client);
6140
6452
  this.functions = new FunctionsService(this._client);
6141
- this.photo = new PhotoService(this._client);
6453
+ this.photo = new PhotoService(this._client, this.storage);
6142
6454
  this.flagContent = new FlagContentService(this._client);
6143
6455
  this.creatorMaker = new CreatorMakerService(this._client);
6144
6456
  this.compliance = new ComplianceService(this._client);
@@ -7223,6 +7535,9 @@ function ScaleMuleProvider({
7223
7535
  client,
7224
7536
  money,
7225
7537
  realtime: baseClient.realtime,
7538
+ storage: baseClient.storage,
7539
+ photo: baseClient.photo,
7540
+ video: baseClient.video,
7226
7541
  user,
7227
7542
  setUser: handleSetUser,
7228
7543
  initializing,
@@ -8535,6 +8850,116 @@ function useContent(options = {}) {
8535
8850
  ]
8536
8851
  );
8537
8852
  }
8853
+ function useMedia() {
8854
+ const { storage, photo, video } = useScaleMule();
8855
+ const [uploading, setUploading] = useState(false);
8856
+ const [error, setError] = useState(null);
8857
+ const upload = useCallback(
8858
+ async (file, options) => {
8859
+ setUploading(true);
8860
+ setError(null);
8861
+ const isPublic = options?.is_public ?? false;
8862
+ const mimeType = file.type || "application/octet-stream";
8863
+ const sharedOpts = {
8864
+ filename: options?.filename,
8865
+ metadata: options?.metadata,
8866
+ onProgress: options?.onProgress,
8867
+ signal: options?.signal
8868
+ };
8869
+ try {
8870
+ if (mimeType.startsWith("image/") && !options?.skipPhotoRegister && !isPublic) {
8871
+ const r2 = await photo.uploadViaStorage(file, sharedOpts);
8872
+ if (r2.error || !r2.data) {
8873
+ throw r2.error ?? { code: "upload_error", message: "Upload failed", status: 0 };
8874
+ }
8875
+ return {
8876
+ file_id: r2.data.file_id,
8877
+ photo_id: r2.data.photo_id,
8878
+ original_view_url: r2.data.original_view_url,
8879
+ optimized_url_promise: r2.data.optimized_url_promise,
8880
+ hls_url_promise: Promise.resolve(null),
8881
+ mime_type: mimeType,
8882
+ is_public: false
8883
+ };
8884
+ }
8885
+ if (mimeType.startsWith("video/") && !isPublic) {
8886
+ const r2 = await video.uploadViaStorage(file, sharedOpts);
8887
+ if (r2.error || !r2.data) {
8888
+ throw r2.error ?? { code: "upload_error", message: "Upload failed", status: 0 };
8889
+ }
8890
+ return {
8891
+ file_id: r2.data.file_id,
8892
+ photo_id: null,
8893
+ original_view_url: r2.data.original_view_url,
8894
+ optimized_url_promise: Promise.resolve(null),
8895
+ hls_url_promise: r2.data.hls_url_promise,
8896
+ mime_type: mimeType,
8897
+ is_public: false
8898
+ };
8899
+ }
8900
+ if (mimeType.startsWith("image/") && isPublic) {
8901
+ const r2 = await storage.upload(file, {
8902
+ ...sharedOpts,
8903
+ isPublic: true,
8904
+ skipCompression: true
8905
+ });
8906
+ if (r2.error || !r2.data) {
8907
+ throw r2.error ?? { code: "upload_error", message: "Upload failed", status: 0 };
8908
+ }
8909
+ const f2 = r2.data;
8910
+ return {
8911
+ file_id: f2.id,
8912
+ photo_id: null,
8913
+ original_view_url: f2.url ?? null,
8914
+ optimized_url_promise: Promise.resolve(null),
8915
+ hls_url_promise: Promise.resolve(null),
8916
+ mime_type: mimeType,
8917
+ is_public: f2.is_public ?? true
8918
+ };
8919
+ }
8920
+ const r = isPublic ? await storage.upload(file, { ...sharedOpts, isPublic: true, skipCompression: true }) : await storage.uploadPrivate(file, sharedOpts);
8921
+ if (r.error || !r.data) {
8922
+ throw r.error ?? { code: "upload_error", message: "Upload failed", status: 0 };
8923
+ }
8924
+ const f = r.data;
8925
+ return {
8926
+ file_id: f.id,
8927
+ photo_id: null,
8928
+ original_view_url: f.url ?? null,
8929
+ optimized_url_promise: Promise.resolve(null),
8930
+ hls_url_promise: Promise.resolve(null),
8931
+ mime_type: mimeType,
8932
+ is_public: f.is_public ?? isPublic
8933
+ };
8934
+ } catch (err) {
8935
+ const e = err;
8936
+ setError(e);
8937
+ throw e;
8938
+ } finally {
8939
+ setUploading(false);
8940
+ }
8941
+ },
8942
+ [storage, photo]
8943
+ );
8944
+ const cancelUpload = useCallback(
8945
+ async (fileId) => {
8946
+ setError(null);
8947
+ try {
8948
+ const r = await storage.delete(fileId);
8949
+ if (r.error) {
8950
+ if (r.error.status === 404) return;
8951
+ throw r.error;
8952
+ }
8953
+ } catch (err) {
8954
+ const e = err;
8955
+ setError(e);
8956
+ throw e;
8957
+ }
8958
+ },
8959
+ [storage]
8960
+ );
8961
+ return { upload, cancelUpload, error, uploading };
8962
+ }
8538
8963
 
8539
8964
  // src/hooks/useMoney.ts
8540
8965
  var useMoney = useMoneyClient;
@@ -9761,6 +10186,10 @@ var STYLES = {
9761
10186
  fontSize: 13,
9762
10187
  color: "#dc2626"
9763
10188
  },
10189
+ note: {
10190
+ fontSize: 13,
10191
+ color: "#475569"
10192
+ },
9764
10193
  success: {
9765
10194
  fontSize: 14,
9766
10195
  fontWeight: 500,
@@ -9770,21 +10199,13 @@ var STYLES = {
9770
10199
  };
9771
10200
  var ALL_TYPES = ["bug_report", "feature_request", "improvement", "other"];
9772
10201
  function FeedbackWidget(props) {
9773
- const {
9774
- position = "bottom-right",
9775
- defaultType = "feature_request",
9776
- allowedTypes = ALL_TYPES,
9777
- triggerLabel = "Feedback",
9778
- theme = "auto",
9779
- className,
9780
- enableRating = true,
9781
- ratingLabel = "How would you rate your experience?",
9782
- onSubmitted
9783
- } = props;
9784
10202
  const sm = useScaleMule();
10203
+ const { client } = sm;
9785
10204
  const { submit } = useFeedback({ enabled: false });
10205
+ const [serverConfig, setServerConfig] = useState(null);
10206
+ const [configLoaded, setConfigLoaded] = useState(false);
9786
10207
  const [open, setOpen] = useState(false);
9787
- const [type, setType] = useState(defaultType);
10208
+ const [type, setType] = useState("feature_request");
9788
10209
  const [title, setTitle] = useState("");
9789
10210
  const [description, setDescription] = useState("");
9790
10211
  const [email, setEmail] = useState("");
@@ -9793,10 +10214,39 @@ function FeedbackWidget(props) {
9793
10214
  const [submitting, setSubmitting] = useState(false);
9794
10215
  const [errMsg, setErrMsg] = useState(null);
9795
10216
  const [done, setDone] = useState(false);
9796
- Boolean(sm.user || sm.user === void 0 ? false : false);
9797
10217
  const signedIn = Boolean(sm.user);
10218
+ const resolvedAllowedTypes = props.allowedTypes ?? serverConfig?.allowed_types ?? ALL_TYPES;
10219
+ const resolvedDefaultType = (props.defaultType && resolvedAllowedTypes.includes(props.defaultType) ? props.defaultType : resolvedAllowedTypes[0]) ?? "feature_request";
10220
+ const resolvedPosition = props.position ?? serverConfig?.widget_position ?? "bottom-right";
10221
+ const resolvedTheme = props.theme ?? serverConfig?.widget_theme ?? "auto";
10222
+ const allowAnonymous = serverConfig?.allow_anonymous ?? true;
10223
+ const anonymousBlocked = !signedIn && !allowAnonymous;
10224
+ useEffect(() => {
10225
+ let active = true;
10226
+ client.get("/v1/feedback/widget-config").then((config) => {
10227
+ if (active) setServerConfig(config);
10228
+ }).catch(() => {
10229
+ if (active) setServerConfig(null);
10230
+ }).finally(() => {
10231
+ if (active) setConfigLoaded(true);
10232
+ });
10233
+ return () => {
10234
+ active = false;
10235
+ };
10236
+ }, [client]);
10237
+ useEffect(() => {
10238
+ setType(
10239
+ (current) => resolvedAllowedTypes.includes(current) ? current : resolvedDefaultType
10240
+ );
10241
+ }, [resolvedAllowedTypes, resolvedDefaultType]);
10242
+ if (!configLoaded) {
10243
+ return null;
10244
+ }
10245
+ if (serverConfig && !serverConfig.enabled) {
10246
+ return null;
10247
+ }
9798
10248
  function reset() {
9799
- setType(defaultType);
10249
+ setType(resolvedDefaultType);
9800
10250
  setTitle("");
9801
10251
  setDescription("");
9802
10252
  setEmail("");
@@ -9808,6 +10258,10 @@ function FeedbackWidget(props) {
9808
10258
  async function handleSubmit(e) {
9809
10259
  e.preventDefault();
9810
10260
  if (!title.trim() || !description.trim()) return;
10261
+ if (anonymousBlocked) {
10262
+ setErrMsg("Please sign in to send feedback for this app");
10263
+ return;
10264
+ }
9811
10265
  if (!signedIn && !email.trim()) {
9812
10266
  setErrMsg("email is required when not signed in");
9813
10267
  return;
@@ -9824,7 +10278,7 @@ function FeedbackWidget(props) {
9824
10278
  tags
9825
10279
  });
9826
10280
  setDone(true);
9827
- onSubmitted?.(item);
10281
+ props.onSubmitted?.(item);
9828
10282
  } catch (err) {
9829
10283
  const message = err && typeof err === "object" && "message" in err ? String(err.message) : "Submit failed";
9830
10284
  setErrMsg(message);
@@ -9836,16 +10290,16 @@ function FeedbackWidget(props) {
9836
10290
  setOpen(false);
9837
10291
  setTimeout(reset, 200);
9838
10292
  }
9839
- const dark = theme === "dark" || theme === "auto" && typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
10293
+ const dark = resolvedTheme === "dark" || resolvedTheme === "auto" && typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
9840
10294
  return /* @__PURE__ */ jsxs(Fragment, { children: [
9841
10295
  /* @__PURE__ */ jsx(
9842
10296
  "button",
9843
10297
  {
9844
10298
  type: "button",
9845
10299
  onClick: () => setOpen(true),
9846
- className,
9847
- style: { ...STYLES.trigger, ...POSITION_STYLES[position] },
9848
- children: triggerLabel
10300
+ className: props.className,
10301
+ style: { ...STYLES.trigger, ...POSITION_STYLES[resolvedPosition] },
10302
+ children: props.triggerLabel ?? "Feedback"
9849
10303
  }
9850
10304
  ),
9851
10305
  open && /* @__PURE__ */ jsx("div", { role: "dialog", "aria-modal": "true", style: STYLES.overlay, onClick: handleClose, children: /* @__PURE__ */ jsx(
@@ -9880,8 +10334,8 @@ function FeedbackWidget(props) {
9880
10334
  }
9881
10335
  )
9882
10336
  ] }),
9883
- enableRating && /* @__PURE__ */ jsxs("div", { children: [
9884
- /* @__PURE__ */ jsx("div", { style: STYLES.label, children: ratingLabel }),
10337
+ props.enableRating !== false && /* @__PURE__ */ jsxs("div", { children: [
10338
+ /* @__PURE__ */ jsx("div", { style: STYLES.label, children: props.ratingLabel ?? "How would you rate your experience?" }),
9885
10339
  /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 4, marginTop: 4 }, children: [
9886
10340
  [1, 2, 3, 4, 5].map((n) => {
9887
10341
  const active = (hoverRating ?? rating ?? 0) >= n;
@@ -9935,7 +10389,7 @@ function FeedbackWidget(props) {
9935
10389
  style: STYLES.field,
9936
10390
  value: type,
9937
10391
  onChange: (e) => setType(e.target.value),
9938
- children: allowedTypes.map((t) => /* @__PURE__ */ jsx("option", { value: t, children: TYPE_LABELS[t] }, t))
10392
+ children: resolvedAllowedTypes.map((t) => /* @__PURE__ */ jsx("option", { value: t, children: TYPE_LABELS[t] }, t))
9939
10393
  }
9940
10394
  )
9941
10395
  ] }),
@@ -9967,7 +10421,7 @@ function FeedbackWidget(props) {
9967
10421
  }
9968
10422
  )
9969
10423
  ] }),
9970
- !signedIn && /* @__PURE__ */ jsxs("label", { children: [
10424
+ !signedIn && allowAnonymous && /* @__PURE__ */ jsxs("label", { children: [
9971
10425
  /* @__PURE__ */ jsx("div", { style: STYLES.label, children: "Email" }),
9972
10426
  /* @__PURE__ */ jsx(
9973
10427
  "input",
@@ -9981,10 +10435,19 @@ function FeedbackWidget(props) {
9981
10435
  }
9982
10436
  )
9983
10437
  ] }),
10438
+ anonymousBlocked && /* @__PURE__ */ jsx("div", { style: STYLES.note, children: "This app only accepts feedback from signed-in users." }),
9984
10439
  errMsg && /* @__PURE__ */ jsx("div", { style: STYLES.error, children: errMsg }),
9985
10440
  /* @__PURE__ */ jsxs("div", { style: STYLES.rowEnd, children: [
9986
10441
  /* @__PURE__ */ jsx("button", { type: "button", style: STYLES.secondaryBtn, onClick: handleClose, children: "Cancel" }),
9987
- /* @__PURE__ */ jsx("button", { type: "submit", style: STYLES.primaryBtn, disabled: submitting, children: submitting ? "Sending\u2026" : "Send" })
10442
+ /* @__PURE__ */ jsx(
10443
+ "button",
10444
+ {
10445
+ type: "submit",
10446
+ style: STYLES.primaryBtn,
10447
+ disabled: submitting || anonymousBlocked,
10448
+ children: submitting ? "Sending\u2026" : "Send"
10449
+ }
10450
+ )
9988
10451
  ] })
9989
10452
  ] })
9990
10453
  }
@@ -10283,4 +10746,4 @@ function createSafeLogger(prefix) {
10283
10746
  };
10284
10747
  }
10285
10748
 
10286
- export { FeedbackWidget, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleProvider, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };
10749
+ export { FeedbackWidget, ScaleMuleApiError, ScaleMuleClient2 as ScaleMuleClient, ScaleMuleProvider, composePhone, createClient, createSafeLogger, normalizePhone, phoneCountries, sanitizeForLog, useAnalytics, useAuth, useBilling, useContent, useFeatureFlags, useFeedback, useMedia, useMoney, useMoneyClient, usePushNotifications, useRealtime, useScaleMule, useScaleMuleClient, useShare, useUser, validateForm, validators };