@getmicdrop/svelte-components 2.8.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1282 @@
1
+ <script>
2
+ import { onMount } from "svelte";
3
+ import { fade, slide } from "svelte/transition";
4
+ import { cubicOut } from "svelte/easing";
5
+ import WarningIcon from "../Icons/WarningIcon.svelte";
6
+ import DarkModeToggle from "../DarkModeToggle.svelte";
7
+ import PasswordStrengthIndicator from "../PasswordStrengthIndicator/PasswordStrengthIndicator.svelte";
8
+ import Input from "../Input/Input.svelte";
9
+ import Button from "../Button/Button.svelte";
10
+ import Checkbox from "../Checkbox/Checkbox.svelte";
11
+
12
+ /**
13
+ * @typedef {Object} Account
14
+ * @property {string} type - Account type (e.g., 'owner', 'performer')
15
+ * @property {string} [organizationName] - Organization name for owner accounts
16
+ * @property {string} token - Auth token
17
+ * @property {string} firstName - User's first name
18
+ * @property {string} lastName - User's last name
19
+ * @property {string} email - User's email
20
+ * @property {string} dashboardUrl - URL to redirect after login
21
+ * @property {Object} [performerProfile] - Performer profile data
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} LoginResult
26
+ * @property {string} token - Auth token
27
+ * @property {string} [refreshToken] - Refresh token
28
+ * @property {string} [firstName] - User's first name
29
+ * @property {Account[]} [accounts] - Multiple accounts if user has more than one
30
+ */
31
+
32
+ let {
33
+ /** @type {string} Base URL for API calls (e.g., 'https://api.micdrop.com' or '' for same-origin) */
34
+ apiBaseUrl = "",
35
+ /** @type {string} Logo image source */
36
+ logoSrc = "",
37
+ /** @type {string} Logo alt text */
38
+ logoAlt = "Logo",
39
+ /** @type {'login' | 'reset' | 'login-link' | 'setup'} Initial view to show */
40
+ initialView = "login",
41
+ /** @type {boolean} Whether this is a first-time user */
42
+ isFirstTime = false,
43
+ /** @type {string} Pre-filled user email */
44
+ userEmail = "",
45
+ /** @type {string} User's first name (for welcome message) */
46
+ firstName = "",
47
+ /** @type {string} Portal type for messaging (e.g., 'performer portal', 'organization dashboard') */
48
+ portalType = "dashboard",
49
+ /** @type {string} Default redirect path after successful login */
50
+ defaultRedirectPath = "/",
51
+ /** @type {string} Default redirect path after first-time setup */
52
+ setupRedirectPath = "/profile",
53
+ /** @type {string} Terms of service URL */
54
+ tosUrl = "https://get-micdrop.com/tos",
55
+ /** @type {(result: LoginResult, rememberMe: boolean) => void} Callback when login succeeds */
56
+ onLoginSuccess = () => {},
57
+ /** @type {(account: Account, rememberMe: boolean) => void} Callback when account is selected */
58
+ onAccountSelect = () => {},
59
+ /** @type {(path: string) => void} Callback for navigation (e.g., goto) */
60
+ onNavigate = () => {},
61
+ /** @type {(url: string) => void} Callback for external navigation */
62
+ onExternalNavigate = (url) => { window.location.href = url; },
63
+ /** @type {boolean} Show dark mode toggle */
64
+ showDarkModeToggle = true,
65
+ /** @type {string} URL search params for setup detection */
66
+ searchParams = "",
67
+ } = $props();
68
+
69
+ // Parse search params
70
+ let parsedParams = $derived.by(() => {
71
+ if (typeof window === 'undefined') return { setup: null, email: null };
72
+ const params = new URLSearchParams(searchParams || window.location.search);
73
+ return {
74
+ setup: params.get('setup'),
75
+ email: params.get('email')?.replace(/ /g, '+') || null
76
+ };
77
+ });
78
+
79
+ let view = $state(initialView);
80
+ let accounts = $state([]);
81
+ let shake = $state(false);
82
+ let showErrors = $state(false);
83
+ let contentHeight = $state(undefined);
84
+ let isViewTransitioning = $state(false);
85
+ let successEmail = $state("");
86
+ let successType = $state("reset");
87
+ let isResendSuccess = $state(false);
88
+ let loginLinkMessage = $state("");
89
+ let cardVisible = $state(false);
90
+
91
+ // Remembered user state for "Welcome back" feature
92
+ let rememberedUser = $state(null);
93
+
94
+ // First name from eligibility check (for setup welcome message)
95
+ let setupFirstName = $state("");
96
+
97
+ // Trigger transition when view changes
98
+ let previousView = $state(initialView);
99
+ $effect(() => {
100
+ if (view !== previousView) {
101
+ previousView = view;
102
+ isViewTransitioning = true;
103
+ setTimeout(() => {
104
+ isViewTransitioning = false;
105
+ }, 300);
106
+ }
107
+ });
108
+
109
+ let authError = $state("");
110
+ let email = $state(userEmail);
111
+ let password = $state("");
112
+ let errors = $state({});
113
+ let isLoading = $state(false);
114
+ let rememberMe = $state(true);
115
+ // Initialize synchronously to prevent flash
116
+ let isCheckingEligibility = $state(parsedParams.setup === "true");
117
+
118
+ let isFormValid = $derived(email.length > 0 && password.length > 0);
119
+
120
+ // Password strength state
121
+ let strengthText = $state("");
122
+ let strengthTextColor = $state("");
123
+ let score = $state(-1);
124
+
125
+ let isSetupValid = $derived(score >= 2);
126
+
127
+ // Track previous values to prevent unnecessary updates
128
+ let prevPasswordLen = $state(0);
129
+ let prevScore = $state(-1);
130
+
131
+ // Show/hide error based on score (debounced from component)
132
+ $effect(() => {
133
+ const currentPasswordLen = password.length;
134
+ const currentScore = score;
135
+
136
+ // Only process if values actually changed
137
+ if (currentPasswordLen === prevPasswordLen && currentScore === prevScore) {
138
+ return;
139
+ }
140
+
141
+ prevPasswordLen = currentPasswordLen;
142
+ prevScore = currentScore;
143
+
144
+ if (currentPasswordLen === 0) {
145
+ // Clear error immediately if password is empty
146
+ if (errors.password) {
147
+ const { password: _, ...rest } = errors;
148
+ errors = rest;
149
+ }
150
+ } else if (currentScore !== -1 && currentScore < 2) {
151
+ errors = {
152
+ ...errors,
153
+ password:
154
+ "Password must be at least 8 characters and include at least 2 of: uppercase, lowercase, numbers, or symbols.",
155
+ };
156
+ showErrors = true;
157
+ } else if (currentScore >= 2) {
158
+ // Clear password error if it's strong enough
159
+ if (errors.password) {
160
+ const { password: _, ...rest } = errors;
161
+ errors = rest;
162
+ }
163
+ }
164
+ });
165
+
166
+ onMount(async () => {
167
+ applyTheme();
168
+
169
+ // Check for remembered user
170
+ try {
171
+ const stored = localStorage.getItem('rememberedUser');
172
+ if (stored) {
173
+ rememberedUser = JSON.parse(stored);
174
+ if (rememberedUser?.email) {
175
+ email = rememberedUser.email;
176
+ }
177
+ }
178
+ } catch (e) {
179
+ // Ignore parse errors
180
+ }
181
+
182
+ // Trigger fade-in animation after a brief delay
183
+ setTimeout(() => {
184
+ cardVisible = true;
185
+ }, 50);
186
+
187
+ const setupParam = parsedParams.setup;
188
+ let emailParam = parsedParams.email;
189
+
190
+ if (setupParam === "true" && emailParam) {
191
+ // isCheckingEligibility is already true from initialization
192
+ email = emailParam;
193
+ await checkEligibility(emailParam);
194
+ isCheckingEligibility = false;
195
+ } else if (isCheckingEligibility) {
196
+ // If setup was true but no email, turn off checking
197
+ isCheckingEligibility = false;
198
+ }
199
+
200
+ // Handle browser back/forward buttons
201
+ const handlePopState = () => {
202
+ const path = window.location.pathname;
203
+ };
204
+
205
+ window.addEventListener("popstate", handlePopState);
206
+ return () => {
207
+ window.removeEventListener("popstate", handlePopState);
208
+ };
209
+ });
210
+
211
+ function applyTheme() {
212
+ const savedTheme = localStorage.getItem("theme");
213
+ const container = document.querySelector(".micdrop");
214
+
215
+ if (container) {
216
+ if (
217
+ savedTheme === "dark" ||
218
+ (!savedTheme &&
219
+ window.matchMedia("(prefers-color-scheme: dark)").matches)
220
+ ) {
221
+ container.classList.add("dark");
222
+ } else {
223
+ container.classList.remove("dark");
224
+ }
225
+ }
226
+ }
227
+
228
+ $effect(() => {
229
+ if (typeof window !== "undefined") {
230
+ applyTheme();
231
+ }
232
+ });
233
+
234
+ async function checkEligibility(emailVal) {
235
+ isLoading = true;
236
+ try {
237
+ const response = await fetch(
238
+ `${apiBaseUrl}/api/public/checkIfFirstUseEligible/${encodeURIComponent(emailVal)}`,
239
+ );
240
+ if (response.ok) {
241
+ const data = await response.json();
242
+ if (data.eligible) {
243
+ if (data.firstName) {
244
+ setupFirstName = data.firstName;
245
+ }
246
+ view = "setup";
247
+ } else {
248
+ errors = {
249
+ email: "This link is no longer valid or has already been used.",
250
+ };
251
+ showErrors = true;
252
+ triggerShake();
253
+ }
254
+ } else {
255
+ errors = { email: "Invalid or expired link." };
256
+ showErrors = true;
257
+ triggerShake();
258
+ }
259
+ } catch (e) {
260
+ console.error(e);
261
+ errors = { email: "Unable to validate link. Please try again later." };
262
+ showErrors = true;
263
+ triggerShake();
264
+ } finally {
265
+ isLoading = false;
266
+ }
267
+ }
268
+
269
+ async function handleSubmit(event) {
270
+ event.preventDefault();
271
+ isLoading = true;
272
+ authError = "";
273
+ const emailValue = email;
274
+ const passwordValue = password;
275
+
276
+ // Enforce minimum spinner duration (1s)
277
+ await new Promise((resolve) => setTimeout(resolve, 1000));
278
+
279
+ // Validate email format
280
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
281
+ if (!emailRegex.test(emailValue)) {
282
+ errors = { email: `Invalid email address: ${emailValue}` };
283
+ triggerShake();
284
+ isLoading = false;
285
+ return;
286
+ }
287
+ try {
288
+ const response = await fetch(`${apiBaseUrl}/api/public/login`, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ },
293
+ body: JSON.stringify({
294
+ email: emailValue.toLowerCase(),
295
+ password: passwordValue,
296
+ }),
297
+ });
298
+
299
+ if (response.ok) {
300
+ const data = await response.json();
301
+ isLoading = false;
302
+ // Clear errors on success
303
+ errors = {};
304
+ showErrors = false;
305
+
306
+ if (data.accounts && data.accounts.length > 1) {
307
+ accounts = data.accounts;
308
+ view = "selection";
309
+ return;
310
+ }
311
+
312
+ // Save remembered user for "Welcome back" feature
313
+ if (rememberMe && data.firstName) {
314
+ saveRememberedUser(emailValue, data.firstName);
315
+ }
316
+
317
+ // Call the success callback
318
+ onLoginSuccess({
319
+ token: data.token,
320
+ refreshToken: data.refreshToken || data.refreshtoken,
321
+ firstName: data.firstName,
322
+ email: emailValue,
323
+ }, rememberMe);
324
+
325
+ onNavigate(defaultRedirectPath);
326
+ } else {
327
+ const errorData = await response.json();
328
+ isLoading = false;
329
+
330
+ // Check if user needs to set up password
331
+ if (errorData.needsSetup && errorData.email) {
332
+ view = "setup";
333
+ return;
334
+ }
335
+
336
+ if (response.status === 401) {
337
+ errors = { password: "Incorrect email or password" };
338
+ } else {
339
+ errors = {
340
+ password: errorData.message || "Login failed. Please try again.",
341
+ };
342
+ }
343
+ triggerShake();
344
+ }
345
+ } catch (error) {
346
+ console.error("Login Error:", error);
347
+ if (error.message === "Network Error" || error.name === "TypeError") {
348
+ errors = {
349
+ password: "Unable to connect. Please check your internet connection.",
350
+ };
351
+ } else {
352
+ errors = { password: "Incorrect email or password" };
353
+ }
354
+ triggerShake();
355
+ isLoading = false;
356
+ }
357
+ }
358
+
359
+ async function handleSetupSubmit() {
360
+ isLoading = true;
361
+ const emailValue = email;
362
+ const passwordValue = password;
363
+
364
+ try {
365
+ const response = await fetch(`${apiBaseUrl}/api/public/setFirstUsePassword`, {
366
+ method: "POST",
367
+ headers: {
368
+ "Content-Type": "application/json",
369
+ },
370
+ credentials: "include",
371
+ body: JSON.stringify({
372
+ email: emailValue,
373
+ password: passwordValue,
374
+ }),
375
+ });
376
+
377
+ if (response.ok) {
378
+ const data = await response.json();
379
+
380
+ // Call the success callback
381
+ onLoginSuccess({
382
+ token: data.token,
383
+ refreshToken: data.refreshToken || data.refreshtoken,
384
+ email: emailValue,
385
+ }, rememberMe);
386
+
387
+ onNavigate(setupRedirectPath);
388
+ } else {
389
+ const errorData = await response.json();
390
+ errors = {
391
+ password:
392
+ errorData.error ||
393
+ "Password must be at least 8 characters and include at least 2 of: uppercase, lowercase, numbers, or symbols.",
394
+ };
395
+ showErrors = true;
396
+ triggerShake();
397
+ }
398
+ } catch (error) {
399
+ console.error("Error setting up account:", error);
400
+ errors = { password: "Network error. Please try again." };
401
+ showErrors = true;
402
+ triggerShake();
403
+ } finally {
404
+ isLoading = false;
405
+ }
406
+ }
407
+
408
+ async function handleResetSubmit() {
409
+ isLoading = true;
410
+ // Enforce minimum spinner duration (1s)
411
+ const minDelay = new Promise((resolve) => setTimeout(resolve, 1000));
412
+ const emailValue = email;
413
+
414
+ // Validate email format
415
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
416
+ if (!emailRegex.test(emailValue)) {
417
+ errors = { email: `Invalid email address: ${emailValue}` };
418
+ triggerShake();
419
+ isLoading = false;
420
+ return false;
421
+ }
422
+
423
+ const apiCall = fetch(`${apiBaseUrl}/api/public/resetPassword`, {
424
+ method: "POST",
425
+ headers: { "Content-Type": "application/json" },
426
+ body: JSON.stringify({ email: emailValue.toLowerCase() }),
427
+ });
428
+
429
+ try {
430
+ const [_, response] = await Promise.all([minDelay, apiCall]);
431
+ isLoading = false;
432
+
433
+ // Always show success to prevent email enumeration
434
+ successEmail = emailValue;
435
+ successType = "reset";
436
+ isResendSuccess = false;
437
+ view = "success";
438
+ return true;
439
+ } catch (error) {
440
+ console.error("Reset Password Error:", error);
441
+ // Show success even on error to prevent enumeration
442
+ successEmail = emailValue;
443
+ successType = "reset";
444
+ isResendSuccess = false;
445
+ view = "success";
446
+ isLoading = false;
447
+ return true;
448
+ }
449
+ }
450
+
451
+ async function handleLoginLinkSubmit() {
452
+ loginLinkMessage = "";
453
+ isLoading = true;
454
+ // Enforce minimum spinner duration (1s)
455
+ const minDelay = new Promise((resolve) => setTimeout(resolve, 1000));
456
+ const emailValue = email;
457
+
458
+ // Validate email format
459
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
460
+ if (!emailRegex.test(emailValue)) {
461
+ errors = { email: `Invalid email address: ${emailValue}` };
462
+ triggerShake();
463
+ isLoading = false;
464
+ return false;
465
+ }
466
+
467
+ const apiCall = fetch(`${apiBaseUrl}/api/public/passwordlessLogin`, {
468
+ method: "POST",
469
+ headers: { "Content-Type": "application/json" },
470
+ body: JSON.stringify({
471
+ email: emailValue.toLowerCase(),
472
+ destination: "dashboard",
473
+ }),
474
+ });
475
+
476
+ try {
477
+ const [_, response] = await Promise.all([minDelay, apiCall]);
478
+
479
+ isLoading = false;
480
+
481
+ // Always show success to prevent email enumeration
482
+ successEmail = emailValue;
483
+ successType = "login-link";
484
+ isResendSuccess = false;
485
+ view = "success";
486
+ return true;
487
+ } catch (error) {
488
+ console.error("Login Link Error:", error);
489
+ // Show success even on error to prevent enumeration
490
+ successEmail = emailValue;
491
+ successType = "login-link";
492
+ isResendSuccess = false;
493
+ view = "success";
494
+ isLoading = false;
495
+ return true;
496
+ }
497
+ }
498
+
499
+ async function handleResend() {
500
+ let success = false;
501
+ if (successType === "reset") {
502
+ success = await handleResetSubmit();
503
+ } else {
504
+ success = await handleLoginLinkSubmit();
505
+ }
506
+
507
+ if (success) {
508
+ isResendSuccess = true;
509
+ }
510
+ }
511
+
512
+ function handleTryDifferentEmail() {
513
+ view = successType;
514
+ errors = {};
515
+ showErrors = false;
516
+ }
517
+
518
+ function handleReturnToSignIn() {
519
+ email = "";
520
+ password = "";
521
+ errors = {};
522
+ showErrors = false;
523
+ view = "login";
524
+ }
525
+
526
+ function handleNotMe() {
527
+ localStorage.removeItem('rememberedUser');
528
+ rememberedUser = null;
529
+ email = "";
530
+ password = "";
531
+ errors = {};
532
+ showErrors = false;
533
+ }
534
+
535
+ function saveRememberedUser(emailVal, firstNameVal) {
536
+ try {
537
+ localStorage.setItem(
538
+ 'rememberedUser',
539
+ JSON.stringify({ email: emailVal, firstName: firstNameVal })
540
+ );
541
+ } catch (e) {
542
+ // Ignore storage errors
543
+ }
544
+ }
545
+
546
+ function handleAccountSelectInternal(account) {
547
+ onAccountSelect(account, rememberMe);
548
+
549
+ // If it's an external URL, redirect there
550
+ if (account.dashboardUrl?.startsWith("http")) {
551
+ onExternalNavigate(account.dashboardUrl);
552
+ return;
553
+ }
554
+
555
+ // Otherwise use internal navigation
556
+ onNavigate(account.dashboardUrl || defaultRedirectPath);
557
+ }
558
+
559
+ function getAccountName(account) {
560
+ if (account.organizationName) {
561
+ return account.organizationName;
562
+ }
563
+
564
+ const profile = account.performerProfile || {};
565
+ const stageName = profile.stageName || account.stageName || "";
566
+ const normalizedStage = stageName?.trim() || "";
567
+ const useStageName = Boolean(
568
+ (profile.useStageName ?? account.useStageName) && normalizedStage,
569
+ );
570
+
571
+ if (useStageName) {
572
+ return normalizedStage;
573
+ }
574
+
575
+ const first = profile.firstName || account.firstName || account.name || "";
576
+ const lastName = profile.lastName || account.lastName || "";
577
+ return `${first} ${lastName}`.trim() || "Performer";
578
+ }
579
+
580
+ function triggerShake() {
581
+ shake = true;
582
+ if (!showErrors) {
583
+ // New error: Wait for shake to finish before showing validation
584
+ setTimeout(() => {
585
+ shake = false;
586
+ showErrors = true;
587
+ }, 625);
588
+ } else {
589
+ // Switching error: Update immediately (already showing), just shake
590
+ setTimeout(() => {
591
+ shake = false;
592
+ }, 625);
593
+ }
594
+ }
595
+ </script>
596
+
597
+ <div
598
+ class="superlogin-container min-h-screen flex items-start justify-center animated-gradient py-12 px-4 sm:px-6 lg:px-8 relative pt-32"
599
+ >
600
+ <!-- Logo positioned top-left -->
601
+ {#if logoSrc}
602
+ <div class="absolute top-8 left-8">
603
+ <img src={logoSrc} alt={logoAlt} class="h-8 w-auto" />
604
+ </div>
605
+ {/if}
606
+
607
+ <div class="w-full flex justify-center px-4">
608
+ <div
609
+ class="login-card bg-white dark:bg-gray-800 rounded-xl shadow-xl overflow-hidden {shake
610
+ ? 'shake'
611
+ : ''} {isViewTransitioning ? 'transition-height' : ''} {cardVisible ? 'card-visible' : 'card-hidden'}"
612
+ style="height: {contentHeight ? contentHeight + 'px' : 'auto'}"
613
+ >
614
+ <div class="login-card-padding" bind:clientHeight={contentHeight}>
615
+ {#if isCheckingEligibility}
616
+ <div class="flex justify-center py-12">
617
+ <div
618
+ class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"
619
+ ></div>
620
+ </div>
621
+ {:else if view === "login"}
622
+ <div in:fade={{ duration: 200 }} class="space-y-6">
623
+ <div class="space-y-2">
624
+ {#if isFirstTime}
625
+ <div>
626
+ <h2
627
+ class="text-2xl font-normal text-gray-900 dark:text-white"
628
+ >
629
+ Welcome{firstName ? `, ${firstName}` : ""}!
630
+ </h2>
631
+ <p class="text-sm text-gray-600 dark:text-gray-300 mt-2">
632
+ Sign in to access your {portalType}.
633
+ </p>
634
+ </div>
635
+ {:else if rememberedUser?.firstName}
636
+ <h2 class="text-2xl font-normal text-gray-900 dark:text-white">
637
+ Welcome back, {rememberedUser.firstName}
638
+ </h2>
639
+ <button
640
+ type="button"
641
+ class="text-sm text-gray-500 hover:text-blue-600 transition-colors"
642
+ onclick={handleNotMe}
643
+ >
644
+ Not {rememberedUser.firstName}?
645
+ </button>
646
+ {:else}
647
+ <h2 class="text-2xl font-normal text-gray-900 dark:text-white">
648
+ Log in
649
+ </h2>
650
+ {/if}
651
+ </div>
652
+
653
+ <form class="space-y-6" onsubmit={handleSubmit}>
654
+ <div class="form-fields-container">
655
+ <div>
656
+ <div class="mb-2">
657
+ <label
658
+ for="email"
659
+ class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
660
+ >Email</label>
661
+ {#if userEmail || rememberedUser?.email}
662
+ <div
663
+ class="w-full h-10 flex items-center bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 px-3"
664
+ >
665
+ <span class="text-sm font-medium text-gray-900 dark:text-white"
666
+ >{userEmail || rememberedUser?.email}</span
667
+ >
668
+ </div>
669
+ {:else}
670
+ <Input
671
+ id="email"
672
+ placeholder=""
673
+ bind:value={email}
674
+ autocomplete="username"
675
+ />
676
+ {/if}
677
+ </div>
678
+ </div>
679
+
680
+ <!-- Slot 2: Email error OR Password field (when no email error) -->
681
+ <div class="error-slot">
682
+ {#if errors.email && showErrors}
683
+ <div
684
+ transition:slide={{ duration: 300, easing: cubicOut }}
685
+ class="flex items-center gap-1.5 text-red-500"
686
+ role="alert"
687
+ >
688
+ <WarningIcon className="shrink-0" />
689
+ <p class="text-sm font-medium">
690
+ {errors.email}
691
+ </p>
692
+ </div>
693
+ {/if}
694
+ </div>
695
+
696
+ <!-- Password field - slides between slot 2 and slot 3 -->
697
+ <div class="password-field-container">
698
+ <div class="mb-2">
699
+ <div class="flex justify-between items-center mb-2">
700
+ <label
701
+ for="password"
702
+ class="block text-sm font-medium text-gray-900 dark:text-white"
703
+ >Password</label>
704
+ <button
705
+ type="button"
706
+ onclick={() => {
707
+ view = "reset";
708
+ }}
709
+ class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
710
+ >
711
+ Forgot your password?
712
+ </button>
713
+ </div>
714
+ <Input
715
+ id="password"
716
+ placeholder=""
717
+ bind:value={password}
718
+ type="password"
719
+ autocomplete="current-password"
720
+ />
721
+ </div>
722
+ </div>
723
+
724
+ <!-- Slot 3/4: Password error -->
725
+ <div class="error-slot">
726
+ {#if errors.password && showErrors}
727
+ <div
728
+ transition:slide={{ duration: 300, easing: cubicOut }}
729
+ class="flex items-center gap-1.5 text-red-500"
730
+ role="alert"
731
+ >
732
+ <WarningIcon className="shrink-0" />
733
+ <p class="text-xs font-medium">
734
+ {errors.password}
735
+ </p>
736
+ </div>
737
+ {/if}
738
+ </div>
739
+ </div>
740
+
741
+ <div class="flex justify-between items-center">
742
+ <div class="flex items-center">
743
+ <Checkbox
744
+ bind:checked={rememberMe}
745
+ id="remember-me"
746
+ >Remember me on this device</Checkbox>
747
+ </div>
748
+ </div>
749
+
750
+ <div class="space-y-4">
751
+ <Button
752
+ size="full"
753
+ type="submit"
754
+ loading={isLoading}
755
+ disabled={!isFormValid}
756
+ >
757
+ Log in
758
+ </Button>
759
+
760
+ <div class="relative">
761
+ <div class="absolute inset-0 flex items-center">
762
+ <div
763
+ class="w-full border-t border-gray-300 dark:border-gray-600"
764
+ ></div>
765
+ </div>
766
+ <div class="relative flex justify-center text-sm">
767
+ <span class="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400"
768
+ >OR</span
769
+ >
770
+ </div>
771
+ </div>
772
+
773
+ <Button
774
+ variant="gray-outline"
775
+ size="full"
776
+ type="button"
777
+ onclick={() => {
778
+ view = "login-link";
779
+ }}
780
+ >
781
+ Send me a login link
782
+ </Button>
783
+ </div>
784
+ </form>
785
+ </div>
786
+ {:else if view === "selection"}
787
+ <div in:fade={{ duration: 200 }} class="space-y-6">
788
+ <div class="space-y-2">
789
+ <h2 class="text-2xl font-normal text-gray-900 dark:text-white">
790
+ Select account
791
+ </h2>
792
+ <p class="text-sm text-gray-600 dark:text-gray-300">
793
+ You have multiple accounts associated with <span
794
+ class="font-medium text-gray-900 dark:text-white"
795
+ >{email}</span
796
+ >. Select one to continue.
797
+ </p>
798
+ </div>
799
+
800
+ <div class="space-y-3">
801
+ {#each accounts as account}
802
+ <button
803
+ class="w-full flex items-center p-4 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-left group"
804
+ onclick={() => handleAccountSelectInternal(account)}
805
+ >
806
+ <div
807
+ class="h-10 w-10 rounded-md bg-gray-200 flex-shrink-0 overflow-hidden mr-4"
808
+ >
809
+ {#if account.performerProfile?.profileImage}
810
+ <img
811
+ src={account.performerProfile.profileImage}
812
+ alt=""
813
+ class="h-full w-full object-cover"
814
+ />
815
+ {:else}
816
+ <div
817
+ class="h-full w-full flex items-center justify-center bg-blue-100 text-blue-600 font-medium"
818
+ >
819
+ {account.organizationName
820
+ ? account.organizationName[0].toUpperCase()
821
+ : account.firstName[0].toUpperCase()}
822
+ </div>
823
+ {/if}
824
+ </div>
825
+ <div class="flex-1 min-w-0">
826
+ <p
827
+ class="text-sm font-medium text-gray-900 dark:text-white truncate"
828
+ >
829
+ {getAccountName(account)}
830
+ </p>
831
+ <p class="text-xs text-gray-500 dark:text-gray-400 capitalize">
832
+ {account.type}
833
+ </p>
834
+ </div>
835
+ <div
836
+ class="text-gray-400 dark:text-gray-500 group-hover:text-gray-600 dark:group-hover:text-gray-300 transition-colors"
837
+ >
838
+ <svg
839
+ xmlns="http://www.w3.org/2000/svg"
840
+ class="h-5 w-5"
841
+ viewBox="0 0 20 20"
842
+ fill="currentColor"
843
+ >
844
+ <path
845
+ fill-rule="evenodd"
846
+ d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
847
+ clip-rule="evenodd"
848
+ />
849
+ </svg>
850
+ </div>
851
+ </button>
852
+ {/each}
853
+ </div>
854
+
855
+ <div class="pt-4 border-t border-gray-300 dark:border-gray-600">
856
+ <button
857
+ type="button"
858
+ class="w-full text-center text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
859
+ onclick={handleReturnToSignIn}
860
+ >
861
+ Log in with a different email
862
+ </button>
863
+ </div>
864
+ </div>
865
+ {:else if view === "setup"}
866
+ <div in:fade={{ duration: 200 }} class="space-y-6">
867
+ <div class="space-y-2">
868
+ <h2 class="text-2xl font-normal text-gray-900 dark:text-white">
869
+ {setupFirstName ? `Welcome, ${setupFirstName}` : "Set up your account"}
870
+ </h2>
871
+ <p class="text-sm text-gray-600 dark:text-gray-300">
872
+ Create a password to finish setting up your account.
873
+ </p>
874
+ </div>
875
+
876
+ <form
877
+ class="space-y-6"
878
+ onsubmit={(e) => { e.preventDefault(); handleSetupSubmit(); }}
879
+ >
880
+ <div class="space-y-5">
881
+ <div>
882
+ <div class="mb-6">
883
+ <div class="flex justify-between items-baseline mb-2">
884
+ <label
885
+ for="setup-password"
886
+ class="block text-sm font-medium text-gray-900 dark:text-white"
887
+ >Create password</label>
888
+ {#if strengthText}
889
+ <span
890
+ class="text-xs font-medium {strengthTextColor} transition-opacity duration-300"
891
+ transition:fade>{strengthText}</span
892
+ >
893
+ {/if}
894
+ </div>
895
+ <Input
896
+ id="setup-password"
897
+ placeholder=""
898
+ bind:value={password}
899
+ type="password"
900
+ autocomplete="new-password"
901
+ />
902
+ <PasswordStrengthIndicator
903
+ {password}
904
+ bind:strengthText
905
+ bind:textColor={strengthTextColor}
906
+ bind:score
907
+ >
908
+ {#if errors.password && showErrors}
909
+ <div
910
+ transition:slide={{ duration: 600, easing: cubicOut }}
911
+ class="flex items-start gap-1.5 text-red-500"
912
+ role="alert"
913
+ >
914
+ <WarningIcon className="shrink-0 mt-1" />
915
+ <p class="text-xs font-medium">
916
+ {errors.password}
917
+ </p>
918
+ </div>
919
+ {/if}
920
+ </PasswordStrengthIndicator>
921
+ </div>
922
+ </div>
923
+ </div>
924
+
925
+ <div class="space-y-4">
926
+ <Button
927
+ size="full"
928
+ type="submit"
929
+ loading={isLoading}
930
+ disabled={!isSetupValid}
931
+ >
932
+ Complete setup
933
+ </Button>
934
+
935
+ <div class="text-center">
936
+ <p class="text-xs text-gray-500 dark:text-gray-400">
937
+ By setting up your account, you agree to Micdrop's <a
938
+ href={tosUrl}
939
+ target="_blank"
940
+ rel="noopener noreferrer"
941
+ class="text-blue-600 hover:underline dark:text-blue-400">terms of service</a
942
+ >.
943
+ </p>
944
+ </div>
945
+ </div>
946
+ </form>
947
+ </div>
948
+ {:else if view === "reset"}
949
+ <div in:fade={{ duration: 200 }} class="space-y-6">
950
+ <div class="space-y-2">
951
+ <h2 class="text-2xl font-normal text-gray-900 dark:text-white">
952
+ Reset your password
953
+ </h2>
954
+ <p class="text-sm text-gray-600 dark:text-gray-300">
955
+ Enter the email address associated with your account and we'll
956
+ send you a link to reset your password.
957
+ </p>
958
+ </div>
959
+
960
+ <form
961
+ class="space-y-6"
962
+ onsubmit={(e) => { e.preventDefault(); handleResetSubmit(); }}
963
+ >
964
+ <div class="space-y-5">
965
+ <div>
966
+ <div class="mb-6">
967
+ <label
968
+ for="reset-email"
969
+ class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
970
+ >Email</label>
971
+ <Input
972
+ id="reset-email"
973
+ placeholder=""
974
+ bind:value={email}
975
+ autocomplete="username"
976
+ />
977
+ </div>
978
+ <div class="error-slot">
979
+ {#if errors.email && showErrors}
980
+ <div
981
+ transition:slide={{ duration: 300, easing: cubicOut }}
982
+ class="flex items-center gap-1.5 text-red-500"
983
+ role="alert"
984
+ >
985
+ <WarningIcon className="shrink-0" />
986
+ <p class="text-sm font-medium">
987
+ {errors.email}
988
+ </p>
989
+ </div>
990
+ {/if}
991
+ </div>
992
+ </div>
993
+ </div>
994
+
995
+ <div class="space-y-4">
996
+ <Button
997
+ size="full"
998
+ type="submit"
999
+ loading={isLoading}
1000
+ disabled={!email}
1001
+ >
1002
+ Send reset link
1003
+ </Button>
1004
+
1005
+ <div class="text-center">
1006
+ <button
1007
+ type="button"
1008
+ onclick={handleReturnToSignIn}
1009
+ class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
1010
+ >
1011
+ Return to log in
1012
+ </button>
1013
+ </div>
1014
+ </div>
1015
+ </form>
1016
+ </div>
1017
+ {:else if view === "login-link"}
1018
+ <div in:fade={{ duration: 200 }} class="space-y-6">
1019
+ <div class="space-y-2">
1020
+ <h2 class="text-2xl font-normal text-gray-900 dark:text-white">
1021
+ Send a login link
1022
+ </h2>
1023
+ <p class="text-sm text-gray-600 dark:text-gray-300">
1024
+ Enter the email address associated with your account and we'll
1025
+ send you a link to sign in.
1026
+ </p>
1027
+ </div>
1028
+
1029
+ <form
1030
+ class="space-y-6"
1031
+ onsubmit={(e) => { e.preventDefault(); handleLoginLinkSubmit(); }}
1032
+ >
1033
+ <div class="space-y-5">
1034
+ <div>
1035
+ <div class="mb-6">
1036
+ <label
1037
+ for="login-link-email"
1038
+ class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
1039
+ >Email</label>
1040
+ <Input
1041
+ id="login-link-email"
1042
+ placeholder=""
1043
+ bind:value={email}
1044
+ autocomplete="username"
1045
+ />
1046
+ </div>
1047
+ <div class="error-slot">
1048
+ {#if errors.email && showErrors}
1049
+ <div
1050
+ transition:slide={{ duration: 300, easing: cubicOut }}
1051
+ class="flex items-center gap-1.5 text-red-500"
1052
+ role="alert"
1053
+ >
1054
+ <WarningIcon className="shrink-0" />
1055
+ <p class="text-sm font-medium">
1056
+ {errors.email}
1057
+ </p>
1058
+ </div>
1059
+ {/if}
1060
+ </div>
1061
+ </div>
1062
+ </div>
1063
+
1064
+ {#if loginLinkMessage}
1065
+ <p
1066
+ class="text-sm {loginLinkMessage.includes('sent') ||
1067
+ loginLinkMessage.includes('check your email')
1068
+ ? 'text-green-600 bg-green-50'
1069
+ : 'text-red-600 bg-red-50'} p-3 rounded-md"
1070
+ role="alert"
1071
+ >
1072
+ {loginLinkMessage}
1073
+ </p>
1074
+ {/if}
1075
+
1076
+ <div class="space-y-4">
1077
+ <Button
1078
+ size="full"
1079
+ type="submit"
1080
+ loading={isLoading}
1081
+ disabled={!email}
1082
+ >
1083
+ Send login link
1084
+ </Button>
1085
+
1086
+ <div class="text-center">
1087
+ <button
1088
+ type="button"
1089
+ onclick={handleReturnToSignIn}
1090
+ class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
1091
+ >
1092
+ Return to log in
1093
+ </button>
1094
+ </div>
1095
+ </div>
1096
+ </form>
1097
+ </div>
1098
+ {:else if view === "success"}
1099
+ <div in:fade={{ duration: 200 }} class="space-y-6 text-center">
1100
+ <div class="space-y-4">
1101
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-white">
1102
+ Check your email
1103
+ </h2>
1104
+ {#if isResendSuccess}
1105
+ <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed text-left">
1106
+ We've resent {successType === "reset"
1107
+ ? "password reset instructions"
1108
+ : "a login link"} to
1109
+ <span class="font-bold text-gray-900 dark:text-white"
1110
+ >{successEmail}</span
1111
+ > if it is an email on file.
1112
+ </p>
1113
+ <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed text-left">
1114
+ Please check again. If you still haven't received an email, <button
1115
+ type="button"
1116
+ class="text-blue-600 font-medium dark:text-blue-400"
1117
+ onclick={handleTryDifferentEmail}
1118
+ >try a different email</button
1119
+ >.
1120
+ </p>
1121
+ {:else}
1122
+ <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed text-left">
1123
+ Thanks! If <span
1124
+ class="font-bold text-gray-900 dark:text-white"
1125
+ >{successEmail}</span
1126
+ >
1127
+ matches an email we have on file, then we've sent you an email
1128
+ containing further instructions for {successType === "reset"
1129
+ ? "resetting your password"
1130
+ : "signing in"}.
1131
+ </p>
1132
+ <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed text-left">
1133
+ If you haven't received an email in 5 minutes, check your
1134
+ spam, <button
1135
+ type="button"
1136
+ class="text-blue-600 font-medium dark:text-blue-400"
1137
+ onclick={handleResend}>resend</button
1138
+ >, or
1139
+ <button
1140
+ type="button"
1141
+ class="text-blue-600 font-medium dark:text-blue-400"
1142
+ onclick={handleTryDifferentEmail}
1143
+ >try a different email</button
1144
+ >.
1145
+ </p>
1146
+ {/if}
1147
+ </div>
1148
+
1149
+ <div class="pt-2">
1150
+ <button
1151
+ type="button"
1152
+ onclick={handleReturnToSignIn}
1153
+ class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
1154
+ >
1155
+ Return to sign in
1156
+ </button>
1157
+ </div>
1158
+ </div>
1159
+ {/if}
1160
+ </div>
1161
+ </div>
1162
+ </div>
1163
+ </div>
1164
+
1165
+ {#if showDarkModeToggle}
1166
+ <div class="fixed bottom-2 flex justify-center w-full">
1167
+ <DarkModeToggle />
1168
+ </div>
1169
+ {/if}
1170
+
1171
+ <style>
1172
+ .superlogin-container :global(body) {
1173
+ background-color: #f8fafc;
1174
+ }
1175
+
1176
+ .animated-gradient {
1177
+ background: linear-gradient(
1178
+ -45deg,
1179
+ #c084fc,
1180
+ #f9a8d4,
1181
+ #93c5fd,
1182
+ #a78bfa,
1183
+ #f0abfc
1184
+ );
1185
+ background-size: 400% 400%;
1186
+ animation: gradientShift 15s ease infinite;
1187
+ }
1188
+
1189
+ @keyframes gradientShift {
1190
+ 0% {
1191
+ background-position: 0% 0%;
1192
+ }
1193
+ 50% {
1194
+ background-position: 100% 100%;
1195
+ }
1196
+ 100% {
1197
+ background-position: 0% 0%;
1198
+ }
1199
+ }
1200
+
1201
+ .transition-height {
1202
+ transition: height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
1203
+ }
1204
+
1205
+ .shake {
1206
+ animation: shake 0.625s linear;
1207
+ }
1208
+
1209
+ @keyframes shake {
1210
+ 8%,
1211
+ 41% {
1212
+ transform: translateX(-10px);
1213
+ }
1214
+ 25%,
1215
+ 58% {
1216
+ transform: translateX(10px);
1217
+ }
1218
+ 75% {
1219
+ transform: translateX(-5px);
1220
+ }
1221
+ 92% {
1222
+ transform: translateX(5px);
1223
+ }
1224
+ 0%,
1225
+ 100% {
1226
+ transform: translateX(0);
1227
+ }
1228
+ }
1229
+
1230
+ .form-fields-container {
1231
+ display: flex;
1232
+ flex-direction: column;
1233
+ gap: 0;
1234
+ }
1235
+
1236
+ .error-slot {
1237
+ margin-top: 4px;
1238
+ margin-bottom: 0;
1239
+ }
1240
+
1241
+ /* Card fade-in animation */
1242
+ .card-hidden {
1243
+ opacity: 0;
1244
+ transform: translateY(12px);
1245
+ }
1246
+
1247
+ .card-visible {
1248
+ opacity: 1;
1249
+ transform: translateY(0);
1250
+ transition: opacity 0.5s ease-out, transform 0.5s ease-out;
1251
+ }
1252
+
1253
+ /* Stripe-matching responsive card sizing */
1254
+ /* Desktop mode - fixed 541px width */
1255
+ .login-card {
1256
+ width: 541px;
1257
+ }
1258
+
1259
+ .login-card-padding {
1260
+ padding: 48px;
1261
+ }
1262
+
1263
+ /* Mobile breakpoint - snaps at 846px viewport width, matches Stripe */
1264
+ @media (max-width: 846px) {
1265
+ .login-card {
1266
+ width: 383px;
1267
+ }
1268
+ .login-card-padding {
1269
+ padding: 32px;
1270
+ }
1271
+ }
1272
+
1273
+ @media (max-width: 480px) {
1274
+ .login-card {
1275
+ width: 100%;
1276
+ margin: 0 16px;
1277
+ }
1278
+ .login-card-padding {
1279
+ padding: 24px;
1280
+ }
1281
+ }
1282
+ </style>