@slyxup/ui 0.1.0 → 0.2.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.
Files changed (74) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +27 -0
  3. package/LICENSE +21 -0
  4. package/README.md +105 -0
  5. package/dist/components/BillingPortal/BillingPortal.d.ts +22 -0
  6. package/dist/components/BillingPortal/BillingPortal.d.ts.map +1 -0
  7. package/dist/components/BillingPortal/BillingPortal.js +59 -0
  8. package/dist/components/BillingPortal/BillingPortal.js.map +1 -0
  9. package/dist/components/EmailVerification/EmailVerification.d.ts +9 -0
  10. package/dist/components/EmailVerification/EmailVerification.d.ts.map +1 -0
  11. package/dist/components/EmailVerification/EmailVerification.js +72 -0
  12. package/dist/components/EmailVerification/EmailVerification.js.map +1 -0
  13. package/dist/components/ForgotPassword/ForgotPassword.d.ts +8 -0
  14. package/dist/components/ForgotPassword/ForgotPassword.d.ts.map +1 -0
  15. package/dist/components/ForgotPassword/ForgotPassword.js +43 -0
  16. package/dist/components/ForgotPassword/ForgotPassword.js.map +1 -0
  17. package/dist/components/PricingTable/PricingTable.d.ts +19 -0
  18. package/dist/components/PricingTable/PricingTable.d.ts.map +1 -0
  19. package/dist/components/PricingTable/PricingTable.js +52 -0
  20. package/dist/components/PricingTable/PricingTable.js.map +1 -0
  21. package/dist/components/ResetPassword/ResetPassword.d.ts +9 -0
  22. package/dist/components/ResetPassword/ResetPassword.d.ts.map +1 -0
  23. package/dist/components/ResetPassword/ResetPassword.js +50 -0
  24. package/dist/components/ResetPassword/ResetPassword.js.map +1 -0
  25. package/dist/components/SignIn/SignIn.d.ts +11 -0
  26. package/dist/components/SignIn/SignIn.d.ts.map +1 -0
  27. package/dist/components/SignIn/SignIn.js +53 -0
  28. package/dist/components/SignIn/SignIn.js.map +1 -0
  29. package/dist/components/SignUp/SignUp.d.ts +8 -0
  30. package/dist/components/SignUp/SignUp.d.ts.map +1 -0
  31. package/dist/components/SignUp/SignUp.js +54 -0
  32. package/dist/components/SignUp/SignUp.js.map +1 -0
  33. package/dist/components/SocialButtons/SocialButtons.d.ts +9 -0
  34. package/dist/components/SocialButtons/SocialButtons.d.ts.map +1 -0
  35. package/dist/components/SocialButtons/SocialButtons.js +16 -0
  36. package/dist/components/SocialButtons/SocialButtons.js.map +1 -0
  37. package/dist/components/UserButton/UserButton.d.ts +3 -0
  38. package/dist/components/UserButton/UserButton.d.ts.map +1 -0
  39. package/dist/components/UserButton/UserButton.js +43 -0
  40. package/dist/components/UserButton/UserButton.js.map +1 -0
  41. package/dist/components/UserProfile/UserProfile.d.ts +3 -0
  42. package/dist/components/UserProfile/UserProfile.d.ts.map +1 -0
  43. package/dist/components/UserProfile/UserProfile.js +38 -0
  44. package/dist/components/UserProfile/UserProfile.js.map +1 -0
  45. package/dist/icons.d.ts +6 -0
  46. package/dist/icons.d.ts.map +1 -0
  47. package/dist/icons.js +15 -0
  48. package/dist/icons.js.map +1 -0
  49. package/dist/index.d.ts +15 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +22 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/styles.d.ts +9 -0
  54. package/dist/styles.d.ts.map +1 -0
  55. package/dist/styles.js +219 -0
  56. package/dist/styles.js.map +1 -0
  57. package/package.json +35 -3
  58. package/src/components/BillingPortal/BillingPortal.tsx +182 -0
  59. package/src/components/EmailVerification/EmailVerification.tsx +180 -0
  60. package/src/components/ForgotPassword/ForgotPassword.tsx +122 -0
  61. package/src/components/PricingTable/PricingTable.tsx +155 -0
  62. package/src/components/ResetPassword/ResetPassword.tsx +120 -0
  63. package/src/components/SignIn/SignIn.tsx +168 -0
  64. package/src/components/SignUp/SignUp.tsx +181 -0
  65. package/src/components/SocialButtons/SocialButtons.tsx +39 -0
  66. package/src/components/UserButton/UserButton.tsx +89 -0
  67. package/src/components/UserProfile/UserProfile.tsx +93 -0
  68. package/src/icons.tsx +71 -0
  69. package/src/index.ts +37 -1
  70. package/src/styles.ts +221 -0
  71. package/test/components.test.tsx +135 -0
  72. package/test/icons.test.tsx +32 -0
  73. package/test/styles.test.ts +47 -0
  74. package/vitest.config.ts +2 -0
package/dist/styles.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * SlyxUp UI — design tokens + stylesheet.
3
+ * Injected once via <SlyxUpStyles />. Theme with CSS variables on :root or .slyxup-scope.
4
+ */
5
+ export const FONT_LINK = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">`;
6
+ export const CSS = `
7
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap');
8
+
9
+ .slyxup-root {
10
+ --slx-accent: #5b5bd6;
11
+ --slx-accent-hover: #4c4cc4;
12
+ --slx-accent-soft: rgba(91, 91, 214, 0.14);
13
+ --slx-bg: #ffffff;
14
+ --slx-bg-page: #e9eaf6;
15
+ --slx-ink: #16161d;
16
+ --slx-muted: #6f6f7b;
17
+ --slx-border: #d8d8e8;
18
+ --slx-danger: #d64550;
19
+ --slx-radius: 12px;
20
+ --slx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
21
+ --slx-display: "Space Grotesk", "DM Sans", sans-serif;
22
+ --slx-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
23
+
24
+ font-family: var(--slx-font);
25
+ color: var(--slx-ink);
26
+ -webkit-font-smoothing: antialiased;
27
+ }
28
+ @media (prefers-color-scheme: dark) {
29
+ .slyxup-root:not(.slyxup-light) {
30
+ --slx-accent: #8484f2;
31
+ --slx-accent-hover: #9696f5;
32
+ --slx-accent-soft: rgba(132, 132, 242, 0.14);
33
+ --slx-bg: #191920;
34
+ --slx-bg-page: #121218;
35
+ --slx-ink: #f2f2f5;
36
+ --slx-muted: #9a9aa6;
37
+ --slx-border: #2c2c36;
38
+ --slx-danger: #f0737d;
39
+ }
40
+ }
41
+
42
+ @keyframes slx-shake {
43
+ 10%, 90% { transform: translateX(-1px); }
44
+ 20%, 80% { transform: translateX(2px); }
45
+ 30%, 50%, 70% { transform: translateX(-4px); }
46
+ 40%, 60% { transform: translateX(4px); }
47
+ }
48
+ @keyframes slx-spin { to { transform: rotate(360deg); } }
49
+ @media (prefers-reduced-motion: reduce) {
50
+ .slyxup-root * { animation: none !important; transition: none !important; }
51
+ }
52
+
53
+ /* ── Card ── */
54
+ .slx-card {
55
+ width: 100%;
56
+ max-width: 380px;
57
+ background: var(--slx-bg);
58
+ border: 1px solid var(--slx-border);
59
+ border-radius: calc(var(--slx-radius) + 4px);
60
+ padding: 32px;
61
+ box-shadow: 0 4px 12px rgba(16,16,29,.08), 0 16px 40px rgba(16,16,29,.12), 0 0 0 1px rgba(16,16,29,.04);
62
+ box-sizing: border-box;
63
+ }
64
+ .slyxup-root .slx-card { background: #ffffff; }
65
+ .slx-card-error { animation: slx-shake .45s cubic-bezier(.36,.07,.19,.97) both; }
66
+
67
+ /* ── Header / keyhole mark ── */
68
+ .slx-mark {
69
+ width: 44px; height: 44px; border-radius: 12px;
70
+ display: flex; align-items: center; justify-content: center;
71
+ background: linear-gradient(135deg, var(--slx-accent), #9a6cf0);
72
+ margin-bottom: 18px;
73
+ }
74
+ .slx-mark svg { display: block; }
75
+ .slx-title { font-family: var(--slx-display); font-size: 20px; font-weight: 650; letter-spacing: -0.02em; margin: 0 0 6px; }
76
+ .slx-subtitle { font-size: 13.5px; color: var(--slx-muted); margin: 0 0 22px; line-height: 1.5; }
77
+
78
+ /* ── Fields ── */
79
+ .slx-field { margin-bottom: 14px; }
80
+ .slx-label { display: block; font-size: 12.5px; font-weight: 550; margin-bottom: 6px; letter-spacing: 0.01em; }
81
+ .slx-input {
82
+ width: 100%; box-sizing: border-box;
83
+ font: inherit; font-size: 14px; color: var(--slx-ink);
84
+ background: transparent;
85
+ border: 1px solid var(--slx-border);
86
+ border-radius: var(--slx-radius);
87
+ padding: 10px 12px;
88
+ outline: none;
89
+ transition: border-color .15s, box-shadow .15s;
90
+ }
91
+ .slx-input::placeholder { color: var(--slx-muted); opacity: .75; }
92
+ .slx-input:focus-visible {
93
+ border-color: var(--slx-accent);
94
+ box-shadow: 0 0 0 3px var(--slx-accent-soft);
95
+ }
96
+ .slx-hint { font-size: 12px; color: var(--slx-muted); margin-top: 5px; }
97
+ .slx-error-text {
98
+ font-size: 13px; color: var(--slx-danger);
99
+ background: color-mix(in srgb, var(--slx-danger) 9%, transparent);
100
+ border-radius: 8px; padding: 9px 11px; margin: 0 0 14px; line-height: 1.4;
101
+ }
102
+
103
+ /* ── Button ── */
104
+ .slx-btn {
105
+ width: 100%; box-sizing: border-box;
106
+ font-family: var(--slx-font); font-size: 14px; font-weight: 600; letter-spacing: 0.01em;
107
+ color: #fff; background: #0a0a0f;
108
+ border: 1px solid #0a0a0f; border-radius: var(--slx-radius);
109
+ padding: 11px 14px; cursor: pointer;
110
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
111
+ transition: background .15s, transform .06s, border-color .15s;
112
+ }
113
+ .slx-btn:hover { background: #1a1a23; border-color: #1a1a23; }
114
+ .slx-btn:active { transform: scale(.985); background: #000; }
115
+ .slx-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(10,10,15,.14), 0 0 0 1px #0a0a0f; }
116
+ .slx-btn[disabled] { opacity: .6; cursor: not-allowed; }
117
+ @media (prefers-color-scheme: dark) {
118
+ .slyxup-root:not(.slyxup-light) .slx-btn { background: #f2f2f5; color: #0a0a0f; border-color: #f2f2f5; }
119
+ .slyxup-root:not(.slyxup-light) .slx-btn:hover { background: #e6e6eb; border-color: #e6e6eb; }
120
+ }
121
+ .slx-spinner {
122
+ width: 15px; height: 15px; flex: none;
123
+ border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
124
+ border-radius: 50%; animation: slx-spin .7s linear infinite;
125
+ }
126
+
127
+ /* ── Social ── */
128
+ .slx-social { display: grid; gap: 10px; margin-bottom: 16px; }
129
+ .slx-social-btn {
130
+ width: 100%; box-sizing: border-box;
131
+ font: inherit; font-size: 13.5px; font-weight: 550;
132
+ color: var(--slx-ink); background: var(--slx-bg);
133
+ border: 1px solid var(--slx-border); border-radius: var(--slx-radius);
134
+ padding: 10px 14px; cursor: pointer;
135
+ display: inline-flex; align-items: center; justify-content: center; gap: 10px;
136
+ transition: background .15s, border-color .15s;
137
+ }
138
+ .slx-social-btn:hover { background: color-mix(in srgb, var(--slx-ink) 4%, var(--slx-bg)); }
139
+ .slx-social-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft); }
140
+
141
+ /* ── Divider & footer ── */
142
+ .slx-divider {
143
+ display: flex; align-items: center; gap: 12px;
144
+ color: var(--slx-muted); font-size: 12px;
145
+ margin: 18px 0;
146
+ }
147
+ .slx-divider::before, .slx-divider::after {
148
+ content: ""; height: 1px; flex: 1; background: var(--slx-border);
149
+ }
150
+ .slx-footer { font-size: 13px; color: var(--slx-muted); margin-top: 20px; text-align: center; }
151
+ .slx-link {
152
+ color: var(--slx-accent); font-weight: 600; text-decoration: none; cursor: pointer;
153
+ background: none; border: none; font: inherit; font-size: inherit;
154
+ padding: 0; margin: 0;
155
+ }
156
+ .slx-link:hover { text-decoration: underline; color: var(--slx-accent-hover); }
157
+ .slx-link:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft); border-radius: 4px; }
158
+
159
+ /* ── Success state ── */
160
+ .slx-success-icon {
161
+ width: 46px; height: 46px; border-radius: 50%;
162
+ display: flex; align-items: center; justify-content: center;
163
+ background: color-mix(in srgb, #34a853 12%, transparent);
164
+ color: #34a853; margin: 4px auto 18px;
165
+ }
166
+
167
+ /* ── User button ── */
168
+ .slx-userbtn-wrap { position: relative; display: inline-block; }
169
+ .slx-userbtn-avatar {
170
+ width: 36px; height: 36px; border-radius: 50%;
171
+ border: 1px solid var(--slx-border); cursor: pointer;
172
+ display: flex; align-items: center; justify-content: center;
173
+ font-size: 14px; font-weight: 650; color: #fff;
174
+ background: linear-gradient(135deg, var(--slx-accent), #9a6cf0);
175
+ padding: 0; overflow: hidden;
176
+ }
177
+ .slx-userbtn-avatar img { width: 100%; height: 100%; object-fit: cover; }
178
+ .slx-userbtn-avatar:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--slx-accent-soft); }
179
+ .slx-menu {
180
+ position: absolute; right: 0; top: calc(100% + 8px);
181
+ min-width: 230px;
182
+ background: var(--slx-bg);
183
+ border: 1px solid var(--slx-border);
184
+ border-radius: var(--slx-radius);
185
+ box-shadow: 0 8px 30px rgba(10,10,20,.14);
186
+ overflow: hidden; z-index: 1000;
187
+ }
188
+ .slx-menu-header { padding: 14px 16px; border-bottom: 1px solid var(--slx-border); }
189
+ .slx-menu-name { font-size: 14px; font-weight: 600; margin: 0 0 2px; }
190
+ .slx-menu-email { font-size: 12.5px; color: var(--slx-muted); margin: 0; word-break: break-all; }
191
+ .slx-menu-item {
192
+ display: block; width: 100%; text-align: left; box-sizing: border-box;
193
+ font: inherit; font-size: 13.5px; color: var(--slx-ink);
194
+ background: none; border: none; padding: 10px 16px; cursor: pointer;
195
+ }
196
+ .slx-menu-item:hover { background: color-mix(in srgb, var(--slx-ink) 5%, transparent); }
197
+ .slx-menu-item:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--slx-accent-soft); }
198
+ .slx-menu-item-danger { color: var(--slx-danger); }
199
+ `;
200
+ let injected = false;
201
+ /** Inject the SlyxUp stylesheet + DM Sans font once per document. */
202
+ export function injectStyles() {
203
+ if (injected || typeof document === 'undefined')
204
+ return;
205
+ // DM Sans font link
206
+ if (!document.querySelector('link[href*="DM+Sans"]')) {
207
+ const fontLink = document.createElement('link');
208
+ fontLink.rel = 'stylesheet';
209
+ fontLink.href =
210
+ 'https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700;9..40,800&family=Space+Grotesk:wght@400;500;600;700&display=swap';
211
+ document.head.appendChild(fontLink);
212
+ }
213
+ const style = document.createElement('style');
214
+ style.setAttribute('data-slyxup', 'styles');
215
+ style.textContent = CSS;
216
+ document.head.appendChild(style);
217
+ injected = true;
218
+ }
219
+ //# sourceMappingURL=styles.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"styles.js","sourceRoot":"","sources":["../src/styles.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,CAAC,MAAM,SAAS,GAAG,6UAA6U,CAAC;AAEvW,MAAM,CAAC,MAAM,GAAG,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiMlB,CAAC;AAEF,IAAI,QAAQ,GAAG,KAAK,CAAC;AAErB,qEAAqE;AACrE,MAAM,UAAU,YAAY;IAC1B,IAAI,QAAQ,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO;IACxD,oBAAoB;IACpB,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC,uBAAuB,CAAC,EAAE,CAAC;QACrD,MAAM,QAAQ,GAAG,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;QAChD,QAAQ,CAAC,GAAG,GAAG,YAAY,CAAC;QAC5B,QAAQ,CAAC,IAAI;YACX,+KAA+K,CAAC;QAClL,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;IACtC,CAAC;IACD,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC9C,KAAK,CAAC,YAAY,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;IAC5C,KAAK,CAAC,WAAW,GAAG,GAAG,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;IACjC,QAAQ,GAAG,IAAI,CAAC;AAClB,CAAC"}
package/package.json CHANGED
@@ -1,9 +1,41 @@
1
1
  {
2
2
  "name": "@slyxup/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "@slyxup/ui — SlyxUp SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
- "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc --noEmit" }
9
- }
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/slyxup/stack.git",
11
+ "directory": "packages/ui"
12
+ },
13
+ "homepage": "https://github.com/slyxup/stack/tree/main/packages/ui#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/slyxup/stack/issues"
16
+ },
17
+ "author": "SlyxUp <hello@slyxup.online>",
18
+ "license": "MIT",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "peerDependencies": {
23
+ "react": "^18 || ^19"
24
+ },
25
+ "dependencies": {
26
+ "@slyxup/core": "0.2.1",
27
+ "@slyxup/react": "0.2.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^26.2.0",
31
+ "@types/react": "^18",
32
+ "react": "^18.3.1",
33
+ "react-dom": "^18.3.1",
34
+ "vitest": "^4.1.11"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run --passWithNoTests"
40
+ }
41
+ }
@@ -0,0 +1,182 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ interface Subscription {
6
+ id: string;
7
+ status: string;
8
+ currentPeriodEnd: string | null;
9
+ cancelAtPeriodEnd: boolean;
10
+ }
11
+ interface Invoice {
12
+ id: string;
13
+ amount: number;
14
+ currency: string;
15
+ status: 'paid' | 'pending' | 'overdue' | 'refunded';
16
+ billedAt: string | null;
17
+ }
18
+
19
+ export interface BillingPortalProps {
20
+ subscription: Subscription | null;
21
+ invoices: Invoice[];
22
+ onCancel?: () => void;
23
+ }
24
+
25
+ /** Current plan + invoices table */
26
+ export function BillingPortal({
27
+ subscription,
28
+ invoices,
29
+ onCancel,
30
+ }: BillingPortalProps) {
31
+ if (!subscription) {
32
+ return (
33
+ <div className="slx-card">
34
+ <h3 style={{ fontSize: 16, fontWeight: 600 }}>
35
+ No active subscription
36
+ </h3>
37
+ <p style={{ fontSize: 13.5, color: 'var(--slx-muted)', marginTop: 6 }}>
38
+ You don&apos;t have a subscription yet. Choose a plan to get started.
39
+ </p>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
46
+ {/* Plan card */}
47
+ <div className="slx-card">
48
+ <div
49
+ style={{
50
+ display: 'flex',
51
+ justifyContent: 'space-between',
52
+ alignItems: 'center',
53
+ }}
54
+ >
55
+ <div>
56
+ <h3 style={{ fontSize: 15, fontWeight: 600 }}>Current plan</h3>
57
+ <p
58
+ style={{ fontSize: 13, color: 'var(--slx-muted)', marginTop: 4 }}
59
+ >
60
+ Status:{' '}
61
+ <span
62
+ style={{
63
+ fontWeight: 650,
64
+ color:
65
+ subscription.status === 'active' ||
66
+ subscription.status === 'trialing'
67
+ ? '#34d399'
68
+ : subscription.status === 'past_due'
69
+ ? 'var(--slx-danger)'
70
+ : 'var(--slx-muted)',
71
+ }}
72
+ >
73
+ {subscription.status}
74
+ </span>
75
+ </p>
76
+ {subscription.currentPeriodEnd && (
77
+ <p
78
+ style={{
79
+ fontSize: 12.5,
80
+ color: 'var(--slx-muted)',
81
+ marginTop: 4,
82
+ }}
83
+ >
84
+ Renews{' '}
85
+ {new Date(subscription.currentPeriodEnd).toLocaleDateString()}
86
+ {subscription.cancelAtPeriodEnd
87
+ ? ' (cancels at period end)'
88
+ : ''}
89
+ </p>
90
+ )}
91
+ </div>
92
+ {subscription.status !== 'canceled' &&
93
+ !subscription.cancelAtPeriodEnd &&
94
+ onCancel && (
95
+ <button
96
+ type="button"
97
+ onClick={onCancel}
98
+ style={{
99
+ fontSize: 13,
100
+ fontWeight: 550,
101
+ background: 'transparent',
102
+ border: '1px solid var(--slx-border)',
103
+ borderRadius: 'var(--slx-radius)',
104
+ padding: '8px 14px',
105
+ cursor: 'pointer',
106
+ color: 'var(--slx-danger)',
107
+ }}
108
+ >
109
+ Cancel
110
+ </button>
111
+ )}
112
+ </div>
113
+ </div>
114
+
115
+ {/* Invoices */}
116
+ {invoices.length > 0 && (
117
+ <div className="slx-card">
118
+ <h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>
119
+ Invoices
120
+ </h3>
121
+ <table
122
+ style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}
123
+ >
124
+ <thead>
125
+ <tr
126
+ style={{
127
+ borderBottom: '1px solid var(--slx-border)',
128
+ textAlign: 'left',
129
+ }}
130
+ >
131
+ <th style={{ padding: '6px 0', fontWeight: 550 }}>Date</th>
132
+ <th style={{ padding: '6px 0', fontWeight: 550 }}>Amount</th>
133
+ <th style={{ padding: '6px 0', fontWeight: 550 }}>Status</th>
134
+ </tr>
135
+ </thead>
136
+ <tbody>
137
+ {invoices.map((inv) => (
138
+ <tr
139
+ key={inv.id}
140
+ style={{ borderBottom: '1px solid var(--slx-border)' }}
141
+ >
142
+ <td style={{ padding: '8px 0' }}>
143
+ {inv.billedAt
144
+ ? new Date(inv.billedAt).toLocaleDateString()
145
+ : '—'}
146
+ </td>
147
+ <td style={{ padding: '8px 0' }}>
148
+ ${(inv.amount / 100).toFixed(2)} {inv.currency}
149
+ </td>
150
+ <td style={{ padding: '8px 0' }}>
151
+ <span
152
+ style={{
153
+ fontSize: 11.5,
154
+ fontWeight: 600,
155
+ padding: '2px 8px',
156
+ borderRadius: 999,
157
+ background:
158
+ inv.status === 'paid'
159
+ ? 'rgba(52,211,153,.1)'
160
+ : inv.status === 'overdue'
161
+ ? 'rgba(214,69,80,.1)'
162
+ : 'rgba(108,108,232,.08)',
163
+ color:
164
+ inv.status === 'paid'
165
+ ? '#34d399'
166
+ : inv.status === 'overdue'
167
+ ? 'var(--slx-danger)'
168
+ : 'var(--slx-accent)',
169
+ }}
170
+ >
171
+ {inv.status}
172
+ </span>
173
+ </td>
174
+ </tr>
175
+ ))}
176
+ </tbody>
177
+ </table>
178
+ </div>
179
+ )}
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,180 @@
1
+ import { type FormEvent, useEffect, useState } from 'react';
2
+ import { CheckIcon, KeyholeMark } from '../../icons';
3
+
4
+ export interface EmailVerificationProps {
5
+ /** Verification token (from email link ?token=...). If absent, shows resend form. */
6
+ token?: string;
7
+ apiUrl?: string;
8
+ onSuccess?: () => void;
9
+ }
10
+
11
+ /** Verify an email with the emailed token, or request a new link. */
12
+ export function EmailVerification({
13
+ token,
14
+ apiUrl,
15
+ onSuccess,
16
+ }: EmailVerificationProps) {
17
+ const [status, setStatus] = useState<
18
+ 'verifying' | 'success' | 'error' | 'resend'
19
+ >(token ? 'verifying' : 'resend');
20
+ const [message, setMessage] = useState<string>('');
21
+ const [email, setEmail] = useState('');
22
+ const [busy, setBusy] = useState(false);
23
+
24
+ useEffect(() => {
25
+ if (!token) return;
26
+ let cancelled = false;
27
+ (async () => {
28
+ try {
29
+ const base = (
30
+ apiUrl ??
31
+ process.env.NEXT_PUBLIC_SLYXUP_API_URL ??
32
+ 'https://auth.slyxup.online'
33
+ ).replace(/\/$/, '');
34
+ const res = await fetch(`${base}/v1/verification/verify`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ token }),
38
+ });
39
+ if (cancelled) return;
40
+ if (res.ok) {
41
+ setStatus('success');
42
+ setTimeout(() => onSuccess?.(), 1800);
43
+ } else {
44
+ const data = await res
45
+ .json()
46
+ .catch(() => ({ error: 'Invalid or expired link' }));
47
+ setMessage(data.error ?? 'Invalid or expired link');
48
+ setStatus('error');
49
+ }
50
+ } catch {
51
+ if (!cancelled) {
52
+ setMessage('Network problem. Try again.');
53
+ setStatus('error');
54
+ }
55
+ }
56
+ })();
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, [token, apiUrl, onSuccess]);
61
+
62
+ async function resend(e: FormEvent) {
63
+ e.preventDefault();
64
+ setBusy(true);
65
+ try {
66
+ const base = (
67
+ apiUrl ??
68
+ process.env.NEXT_PUBLIC_SLYXUP_API_URL ??
69
+ 'https://auth.slyxup.online'
70
+ ).replace(/\/$/, '');
71
+ await fetch(`${base}/v1/verification/resend`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ email }),
75
+ });
76
+ setMessage(
77
+ 'If that account exists, a new verification link is on its way.'
78
+ );
79
+ } catch {
80
+ setMessage('Network problem. Try again.');
81
+ } finally {
82
+ setBusy(false);
83
+ }
84
+ }
85
+
86
+ return (
87
+ <div className="slx-card">
88
+ <div className="slx-mark">
89
+ <KeyholeMark />
90
+ </div>
91
+
92
+ {status === 'verifying' && (
93
+ <>
94
+ <h1 className="slx-title">Verifying your email…</h1>
95
+ <p className="slx-subtitle">One moment while we confirm.</p>
96
+ </>
97
+ )}
98
+
99
+ {status === 'success' && (
100
+ <>
101
+ <div className="slx-success-icon">
102
+ <CheckIcon />
103
+ </div>
104
+ <h1 className="slx-title" style={{ textAlign: 'center' }}>
105
+ Email verified
106
+ </h1>
107
+ <p className="slx-subtitle" style={{ textAlign: 'center' }}>
108
+ You&apos;re all set. Redirecting…
109
+ </p>
110
+ </>
111
+ )}
112
+
113
+ {status === 'error' && (
114
+ <>
115
+ <h1 className="slx-title">Link didn&apos;t work</h1>
116
+ {message && (
117
+ <p className="slx-error-text" role="alert">
118
+ {message}
119
+ </p>
120
+ )}
121
+ <p className="slx-subtitle">Request a fresh link below.</p>
122
+ <form onSubmit={resend}>
123
+ <div className="slx-field">
124
+ <label className="slx-label" htmlFor="slx-verify-email">
125
+ Email
126
+ </label>
127
+ <input
128
+ id="slx-verify-email"
129
+ className="slx-input"
130
+ type="email"
131
+ placeholder="you@example.com"
132
+ value={email}
133
+ onChange={(e) => setEmail(e.target.value)}
134
+ required
135
+ />
136
+ </div>
137
+ <button className="slx-btn" type="submit" disabled={busy}>
138
+ {busy && <span className="slx-spinner" aria-hidden="true" />}
139
+ {busy ? 'Sending…' : 'Resend verification email'}
140
+ </button>
141
+ </form>
142
+ </>
143
+ )}
144
+
145
+ {status === 'resend' && (
146
+ <>
147
+ <h1 className="slx-title">Verify your email</h1>
148
+ <p className="slx-subtitle">
149
+ Enter your email and we&apos;ll send a verification link.
150
+ </p>
151
+ {message && (
152
+ <p className="slx-hint" style={{ marginBottom: 12 }}>
153
+ {message}
154
+ </p>
155
+ )}
156
+ <form onSubmit={resend}>
157
+ <div className="slx-field">
158
+ <label className="slx-label" htmlFor="slx-verify-email2">
159
+ Email
160
+ </label>
161
+ <input
162
+ id="slx-verify-email2"
163
+ className="slx-input"
164
+ type="email"
165
+ placeholder="you@example.com"
166
+ value={email}
167
+ onChange={(e) => setEmail(e.target.value)}
168
+ required
169
+ />
170
+ </div>
171
+ <button className="slx-btn" type="submit" disabled={busy}>
172
+ {busy && <span className="slx-spinner" aria-hidden="true" />}
173
+ {busy ? 'Sending…' : 'Send verification link'}
174
+ </button>
175
+ </form>
176
+ </>
177
+ )}
178
+ </div>
179
+ );
180
+ }