epsimo-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/SKILL.md +85 -0
  3. package/assets/example_asset.txt +24 -0
  4. package/epsimo/__init__.py +3 -0
  5. package/epsimo/__main__.py +4 -0
  6. package/epsimo/auth.py +143 -0
  7. package/epsimo/cli.py +586 -0
  8. package/epsimo/client.py +53 -0
  9. package/epsimo/resources/assistants.py +47 -0
  10. package/epsimo/resources/credits.py +16 -0
  11. package/epsimo/resources/db.py +31 -0
  12. package/epsimo/resources/files.py +39 -0
  13. package/epsimo/resources/projects.py +30 -0
  14. package/epsimo/resources/threads.py +83 -0
  15. package/epsimo/templates/components/AuthModal/AuthModal.module.css +39 -0
  16. package/epsimo/templates/components/AuthModal/AuthModal.tsx +138 -0
  17. package/epsimo/templates/components/BuyCredits/BuyCreditsModal.module.css +96 -0
  18. package/epsimo/templates/components/BuyCredits/BuyCreditsModal.tsx +132 -0
  19. package/epsimo/templates/components/BuyCredits/CreditsDisplay.tsx +101 -0
  20. package/epsimo/templates/components/ThreadChat/ThreadChat.module.css +551 -0
  21. package/epsimo/templates/components/ThreadChat/ThreadChat.tsx +862 -0
  22. package/epsimo/templates/components/ThreadChat/components/ToolRenderers.module.css +509 -0
  23. package/epsimo/templates/components/ThreadChat/components/ToolRenderers.tsx +322 -0
  24. package/epsimo/templates/next-mvp/app/globals.css.tmpl +20 -0
  25. package/epsimo/templates/next-mvp/app/layout.tsx.tmpl +22 -0
  26. package/epsimo/templates/next-mvp/app/page.module.css.tmpl +84 -0
  27. package/epsimo/templates/next-mvp/app/page.tsx.tmpl +43 -0
  28. package/epsimo/templates/next-mvp/epsimo.yaml.tmpl +12 -0
  29. package/epsimo/templates/next-mvp/package.json.tmpl +26 -0
  30. package/epsimo/tools/library.yaml +51 -0
  31. package/package.json +27 -0
  32. package/references/api_reference.md +34 -0
  33. package/references/virtual_db_guide.md +57 -0
  34. package/requirements.txt +2 -0
  35. package/scripts/assistant.py +165 -0
  36. package/scripts/auth.py +195 -0
  37. package/scripts/credits.py +107 -0
  38. package/scripts/debug_run.py +41 -0
  39. package/scripts/example.py +19 -0
  40. package/scripts/files.py +73 -0
  41. package/scripts/find_thread.py +55 -0
  42. package/scripts/project.py +60 -0
  43. package/scripts/run.py +75 -0
  44. package/scripts/test_all_skills.py +387 -0
  45. package/scripts/test_sdk.py +83 -0
  46. package/scripts/test_streaming.py +167 -0
  47. package/scripts/test_vdb.py +65 -0
  48. package/scripts/thread.py +77 -0
  49. package/scripts/verify_skill.py +87 -0
@@ -0,0 +1,16 @@
1
+ class Credits:
2
+ def __init__(self, client):
3
+ self.client = client
4
+
5
+ def get_balance(self):
6
+ """Retrieve the current thread and credit balance."""
7
+ # Using the thread-info endpoint as it contains current balance data
8
+ return self.client.request("GET", "/auth/thread-info")
9
+
10
+ def create_checkout_session(self, quantity, total_amount):
11
+ """Create a checkout session to buy credits."""
12
+ payload = {
13
+ "quantity": quantity,
14
+ "total_amount": float(total_amount)
15
+ }
16
+ return self.client.request("POST", "/checkout/create-checkout-session", json=payload)
@@ -0,0 +1,31 @@
1
+ class Database:
2
+ """
3
+ The Database resource allows using Epsimo threads as a virtual structured storage.
4
+ It wraps thread state management into a familiar key-value or document-based interface.
5
+ """
6
+ def __init__(self, client):
7
+ self.client = client
8
+
9
+ def get_all(self, project_id, thread_id):
10
+ """Retrieve all structured data stored in the thread state."""
11
+ state = self.client.threads.get_state(project_id, thread_id)
12
+ return state.get("values", {})
13
+
14
+ def get(self, project_id, thread_id, key, default=None):
15
+ """Retrieve a specific key from the thread state."""
16
+ values = self.get_all(project_id, thread_id)
17
+ if isinstance(values, dict):
18
+ return values.get(key, default)
19
+ return default
20
+
21
+ def set(self, project_id, thread_id, key, value):
22
+ """
23
+ Store a value in the thread state.
24
+ Note: This currently attempts a manual state update which may be restricted
25
+ depending on assistant configuration.
26
+ """
27
+ return self.client.threads.set_state(project_id, thread_id, {key: value})
28
+
29
+ def update(self, project_id, thread_id, data):
30
+ """Bulk update the thread state with a dictionary of values."""
31
+ return self.client.threads.set_state(project_id, thread_id, data)
@@ -0,0 +1,39 @@
1
+ import os
2
+
3
+ class Files:
4
+ def __init__(self, client):
5
+ self.client = client
6
+
7
+ def list(self, project_id, assistant_id):
8
+ """List files attached to an assistant."""
9
+ headers = self.client.get_project_headers(project_id)
10
+ return self.client.request("GET", f"/assistants/{assistant_id}/files", headers=headers)
11
+
12
+ def upload(self, project_id, assistant_id, file_path):
13
+ """Upload a file to an assistant."""
14
+ headers = self.client.get_project_headers(project_id)
15
+ # requests handles Content-Type for files
16
+ # We need to act directly on the session or expose a clean method in client
17
+
18
+ # client.request takes json/params but not files explicitly in my simple wrapper
19
+ # Let's bypass wrapper slightly or extend it, but here we construct full URL
20
+ # reusing client.base_url
21
+
22
+ url = f"{self.client.base_url}/assistants/{assistant_id}/files"
23
+
24
+ # We need to strip standard JSON content type if present
25
+ # but headers only has Auth here.
26
+
27
+ with open(file_path, 'rb') as f:
28
+ files = {'files': (os.path.basename(file_path), f)}
29
+ # accessing _session directly for file upload flexibility
30
+ resp = self.client._session.post(url, headers=headers, files=files)
31
+
32
+ if not resp.ok:
33
+ resp.raise_for_status()
34
+ return resp.json()
35
+
36
+ def delete(self, project_id, assistant_id, file_id):
37
+ """Delete a file from an assistant."""
38
+ headers = self.client.get_project_headers(project_id)
39
+ return self.client.request("DELETE", f"/assistants/{assistant_id}/files/{file_id}", headers=headers)
@@ -0,0 +1,30 @@
1
+ class Projects:
2
+ def __init__(self, client):
3
+ self.client = client
4
+
5
+ def list(self):
6
+ """List all projects."""
7
+ return self.client.request("GET", "/projects/")
8
+
9
+ def create(self, name, description="Epsimo Project"):
10
+ """Create a new project."""
11
+ payload = {"name": name, "description": description}
12
+ return self.client.request("POST", "/projects/", json=payload)
13
+
14
+ def get(self, project_id):
15
+ """Get project details (and token context switching)."""
16
+ return self.client.request("GET", f"/projects/{project_id}")
17
+
18
+ def update(self, project_id, name=None, description=None):
19
+ """Update a project."""
20
+ payload = {}
21
+ if name: payload["name"] = name
22
+ if description: payload["description"] = description
23
+ return self.client.request("PUT", f"/projects/{project_id}", json=payload)
24
+
25
+ def delete(self, project_id, confirm=False):
26
+ """Delete a project."""
27
+ url = f"/projects/{project_id}"
28
+ if confirm:
29
+ url += "?confirm=true"
30
+ return self.client.request("DELETE", url)
@@ -0,0 +1,83 @@
1
+ import json
2
+
3
+ class Threads:
4
+ def __init__(self, client):
5
+ self.client = client
6
+
7
+ def list(self, project_id):
8
+ """List threads in a project."""
9
+ headers = self.client.get_project_headers(project_id)
10
+ return self.client.request("GET", "/threads/", headers=headers)
11
+
12
+ def create(self, project_id, name, assistant_id, metadata=None):
13
+ """Create a new thread."""
14
+ headers = self.client.get_project_headers(project_id)
15
+ payload = {
16
+ "name": name,
17
+ "assistant_id": assistant_id,
18
+ "metadata": metadata or {"type": "thread"}
19
+ }
20
+ return self.client.request("POST", "/threads/", json=payload, headers=headers)
21
+
22
+ def get(self, project_id, thread_id):
23
+ """Get thread details."""
24
+ headers = self.client.get_project_headers(project_id)
25
+ return self.client.request("GET", f"/threads/{thread_id}", headers=headers)
26
+
27
+ def get_state(self, project_id, thread_id):
28
+ """Retrieve the structured state (values) of a thread."""
29
+ headers = self.client.get_project_headers(project_id)
30
+ return self.client.request("GET", f"/threads/{thread_id}/state", headers=headers)
31
+
32
+ def set_state(self, project_id, thread_id, values, config=None):
33
+ """Update the structured state (values) of a thread."""
34
+ headers = self.client.get_project_headers(project_id)
35
+ payload = {
36
+ "values": values,
37
+ "config": config or {}
38
+ }
39
+ return self.client.request("POST", f"/threads/{thread_id}/state", json=payload, headers=headers)
40
+
41
+ # --- Runs (Streaming) ---
42
+ # Putting this here for convenience as Runs are usually per-thread/assistant
43
+
44
+ def run_stream(self, project_id, thread_id, assistant_id, message, stream_mode=None):
45
+ """
46
+ Stream a run and yield chunks.
47
+
48
+ Args:
49
+ project_id: Project ID
50
+ thread_id: Thread ID
51
+ assistant_id: Assistant ID
52
+ message: User message input string
53
+ stream_mode: List of modes, e.g. ["messages", "values"]
54
+
55
+ Yields:
56
+ Parsed JSON chunks from the SSE stream.
57
+ """
58
+ headers = self.client.get_project_headers(project_id)
59
+ headers["Accept"] = "text/event-stream"
60
+
61
+ payload = {
62
+ "thread_id": thread_id,
63
+ "assistant_id": assistant_id,
64
+ "input": [{"role": "user", "content": message, "type": "human"}],
65
+ "stream_mode": stream_mode or ["messages", "values"]
66
+ }
67
+
68
+ # Bypass client.request to handle streaming
69
+ url = f"{self.client.base_url}/runs/stream"
70
+ response = self.client._session.post(url, json=payload, headers=headers, stream=True)
71
+ response.raise_for_status()
72
+
73
+ for line in response.iter_lines():
74
+ if line:
75
+ decoded = line.decode('utf-8')
76
+ if decoded.startswith("data:"):
77
+ data_str = decoded[5:].strip()
78
+ if data_str == "[DONE]":
79
+ break
80
+ try:
81
+ yield json.loads(data_str)
82
+ except json.JSONDecodeError:
83
+ yield {"raw": data_str, "error": "json_decode_error"}
@@ -0,0 +1,39 @@
1
+ .footer {
2
+ padding: 1rem 0 0;
3
+ margin-top: 1rem;
4
+ border-top: 1px solid var(--border-color);
5
+ text-align: center;
6
+ font-size: 0.875rem;
7
+ color: var(--text-secondary);
8
+ }
9
+
10
+ .switchBtn {
11
+ background: none;
12
+ border: none;
13
+ color: var(--color-primary);
14
+ font-weight: 500;
15
+ cursor: pointer;
16
+ padding: 0 4px;
17
+ }
18
+
19
+ .switchBtn:hover {
20
+ text-decoration: underline;
21
+ }
22
+
23
+ .form {
24
+ display: flex;
25
+ flex-direction: column;
26
+ gap: 1rem;
27
+ }
28
+
29
+ .errorAlert {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 8px;
33
+ padding: 10px;
34
+ background-color: rgba(220, 38, 38, 0.1);
35
+ border: 1px solid rgba(220, 38, 38, 0.2);
36
+ border-radius: var(--radius-md);
37
+ color: #ef4444;
38
+ font-size: 0.875rem;
39
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ import React, { useState } from 'react';
4
+ import { useAuth } from '@/contexts/AuthContext'; // Assumes user has this context
5
+ import { api } from '@/lib/api-client'; // Assumes user has this client
6
+ import { Modal } from '@/components/ui/Modal/Modal';
7
+ import { Input } from '@/components/ui/Input/Input';
8
+ import { Button } from '@/components/ui/Button/Button';
9
+ import styles from './AuthModal.module.css';
10
+
11
+ interface AuthModalProps {
12
+ isOpen: boolean;
13
+ onClose: () => void;
14
+ onSuccess?: () => void;
15
+ defaultTab?: 'login' | 'signup';
16
+ }
17
+
18
+ export const AuthModal: React.FC<AuthModalProps> = ({
19
+ isOpen,
20
+ onClose,
21
+ onSuccess,
22
+ defaultTab = 'login'
23
+ }) => {
24
+ const [mode, setMode] = useState<'login' | 'signup'>(defaultTab);
25
+ const [email, setEmail] = useState('');
26
+ const [password, setPassword] = useState('');
27
+ const [name, setName] = useState('');
28
+ const [isLoading, setIsLoading] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ const { login } = useAuth();
32
+
33
+ const handleSubmit = async (e: React.FormEvent) => {
34
+ e.preventDefault();
35
+ setIsLoading(true);
36
+ setError(null);
37
+
38
+ try {
39
+ if (mode === 'login') {
40
+ const response = await api.post('/auth/login', {
41
+ body: { email, password }
42
+ });
43
+ login(response.jwt_token, response.main_agent_id, response.main_thread_id);
44
+ } else {
45
+ const response = await api.post('/auth/signup', {
46
+ body: { email, password, name }
47
+ });
48
+ // Auto-login after signup
49
+ if (response.jwt_token) {
50
+ login(response.jwt_token, response.main_agent_id, response.main_thread_id);
51
+ }
52
+ }
53
+
54
+ if (onSuccess) onSuccess();
55
+ onClose();
56
+ } catch (err: any) {
57
+ console.error('Auth error:', err);
58
+ setError(err.message || `Failed to ${mode}. Please check your details.`);
59
+ } finally {
60
+ setIsLoading(false);
61
+ }
62
+ };
63
+
64
+ return (
65
+ <Modal
66
+ isOpen={isOpen}
67
+ onClose={onClose}
68
+ title={mode === 'login' ? 'Welcome Back' : 'Create Account'}
69
+ size="sm"
70
+ footer={
71
+ <div className={styles.footer}>
72
+ <p>
73
+ {mode === 'login' ? "Don't have an account? " : "Already have an account? "}
74
+ <button
75
+ type="button"
76
+ className={styles.switchBtn}
77
+ onClick={() => {
78
+ setMode(mode === 'login' ? 'signup' : 'login');
79
+ setError(null);
80
+ }}
81
+ >
82
+ {mode === 'login' ? 'Sign up' : 'Log in'}
83
+ </button>
84
+ </p>
85
+ </div>
86
+ }
87
+ >
88
+ <form onSubmit={handleSubmit} className={styles.form}>
89
+ {mode === 'signup' && (
90
+ <Input
91
+ label="Full Name"
92
+ placeholder="John Doe"
93
+ value={name}
94
+ onChange={(e) => setName(e.target.value)}
95
+ required
96
+ fullWidth
97
+ />
98
+ )}
99
+
100
+ <Input
101
+ type="email"
102
+ label="Email"
103
+ placeholder="name@example.com"
104
+ value={email}
105
+ onChange={(e) => setEmail(e.target.value)}
106
+ required
107
+ fullWidth
108
+ />
109
+
110
+ <Input
111
+ type="password"
112
+ label="Password"
113
+ placeholder="••••••••"
114
+ value={password}
115
+ onChange={(e) => setPassword(e.target.value)}
116
+ required
117
+ fullWidth
118
+ minLength={8}
119
+ />
120
+
121
+ {error && (
122
+ <div className={styles.errorAlert}>
123
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
124
+ <span>{error}</span>
125
+ </div>
126
+ )}
127
+
128
+ <Button
129
+ type="submit"
130
+ fullWidth
131
+ isLoading={isLoading}
132
+ >
133
+ {mode === 'login' ? 'Sign In' : 'Create Account'}
134
+ </Button>
135
+ </form>
136
+ </Modal>
137
+ );
138
+ };
@@ -0,0 +1,96 @@
1
+ .pricingGrid {
2
+ display: grid;
3
+ grid-template-columns: repeat(3, 1fr);
4
+ gap: 16px;
5
+ margin-top: 24px;
6
+ }
7
+
8
+ .pricingCard {
9
+ background: var(--background-surface);
10
+ border: 1px solid var(--border-color);
11
+ border-radius: var(--radius-lg);
12
+ padding: 24px;
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ text-align: center;
17
+ transition: all 0.2s ease;
18
+ cursor: pointer;
19
+ position: relative;
20
+ overflow: hidden;
21
+ }
22
+
23
+ .pricingCard:hover {
24
+ border-color: var(--color-primary);
25
+ transform: translateY(-2px);
26
+ box-shadow: var(--shadow-md);
27
+ }
28
+
29
+ .selectedCard {
30
+ border-color: var(--color-primary);
31
+ background: rgba(var(--color-primary-rgb), 0.03);
32
+ box-shadow: 0 0 0 2px var(--color-primary-light);
33
+ }
34
+
35
+ .popularBadge {
36
+ position: absolute;
37
+ top: 12px;
38
+ right: 12px;
39
+ background: var(--color-primary);
40
+ color: white;
41
+ font-size: 0.7rem;
42
+ padding: 2px 8px;
43
+ border-radius: 12px;
44
+ font-weight: 600;
45
+ }
46
+
47
+ .threads {
48
+ font-size: 2rem;
49
+ font-weight: 700;
50
+ color: var(--text-primary);
51
+ margin: 8px 0;
52
+ }
53
+
54
+ .price {
55
+ font-size: 1.1rem;
56
+ color: var(--text-secondary);
57
+ margin-bottom: 24px;
58
+ }
59
+
60
+ .customInput {
61
+ margin-top: 24px;
62
+ width: 100%;
63
+ padding-top: 24px;
64
+ border-top: 1px solid var(--border-color);
65
+ }
66
+
67
+ .loadingOverlay {
68
+ position: absolute;
69
+ inset: 0;
70
+ background: rgba(255, 255, 255, 0.8);
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ z-index: 10;
75
+ }
76
+
77
+ .spinner {
78
+ width: 24px;
79
+ height: 24px;
80
+ border: 2px solid var(--border-color);
81
+ border-top: 2px solid var(--color-primary);
82
+ border-radius: 50%;
83
+ animation: spin 1s linear infinite;
84
+ }
85
+
86
+ @keyframes spin {
87
+ to {
88
+ transform: rotate(360deg);
89
+ }
90
+ }
91
+
92
+ @media (max-width: 640px) {
93
+ .pricingGrid {
94
+ grid-template-columns: 1fr;
95
+ }
96
+ }
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import React, { useState } from 'react';
4
+ import { api } from '@/lib/api-client';
5
+ import { Modal } from '@/components/ui/Modal/Modal';
6
+ import { Button } from '@/components/ui/Button/Button';
7
+ import { Input } from '@/components/ui/Input/Input';
8
+ import styles from './BuyCreditsModal.module.css';
9
+
10
+ interface BuyCreditsModalProps {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ tokenPrice?: number; // Price per token/thread
14
+ }
15
+
16
+ export const BuyCreditsModal: React.FC<BuyCreditsModalProps> = ({
17
+ isOpen,
18
+ onClose,
19
+ tokenPrice = 0.10 // Default fallback price
20
+ }) => {
21
+ const [quantity, setQuantity] = useState<number>(100);
22
+ const [isCustom, setIsCustom] = useState(false);
23
+ const [isLoading, setIsLoading] = useState(false);
24
+ const [error, setError] = useState<string | null>(null);
25
+
26
+ const packages = [
27
+ { name: 'Starter', threads: 100, price: 10, popular: false },
28
+ { name: 'Pro', threads: 500, price: 45, popular: true },
29
+ { name: 'Max', threads: 1000, price: 80, popular: false },
30
+ ];
31
+
32
+ const currentPrice = isCustom ? (quantity * tokenPrice).toFixed(2) : (packages.find(p => p.threads === quantity)?.price || (quantity * tokenPrice)).toFixed(2);
33
+
34
+ const handleCheckout = async () => {
35
+ setIsLoading(true);
36
+ setError(null);
37
+
38
+ try {
39
+ const response: any = await api.post('/checkout/create-checkout-session', {
40
+ body: {
41
+ quantity,
42
+ total_amount: parseFloat(currentPrice)
43
+ }
44
+ });
45
+
46
+ if (response && response.url) {
47
+ window.location.href = response.url;
48
+ } else {
49
+ throw new Error('Invalid checkout response');
50
+ }
51
+ } catch (err: any) {
52
+ console.error('Checkout error:', err);
53
+ setError(err.message || 'Failed to start checkout');
54
+ setIsLoading(false);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <Modal
60
+ isOpen={isOpen}
61
+ onClose={onClose}
62
+ title="Add More Threads"
63
+ size="lg"
64
+ footer={
65
+ <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', alignItems: 'center', width: '100%' }}>
66
+ <div style={{ marginRight: 'auto', fontWeight: 'bold' }}>
67
+ Total: €{currentPrice}
68
+ </div>
69
+ <Button variant="ghost" onClick={onClose} disabled={isLoading}>
70
+ Cancel
71
+ </Button>
72
+ <Button onClick={handleCheckout} isLoading={isLoading}>
73
+ Proceed to Checkout
74
+ </Button>
75
+ </div>
76
+ }
77
+ >
78
+ <div className={styles.container}>
79
+ {error && (
80
+ <div className="bg-red-50 text-red-600 p-3 rounded-md mb-4 text-sm">
81
+ {error}
82
+ </div>
83
+ )}
84
+
85
+ <p style={{ color: 'var(--text-secondary)', marginBottom: '20px' }}>
86
+ Purchase additional threads to continue your conversations.
87
+ </p>
88
+
89
+ <div className={styles.pricingGrid}>
90
+ {packages.map((pkg) => (
91
+ <div
92
+ key={pkg.name}
93
+ className={`${styles.pricingCard} ${quantity === pkg.threads && !isCustom ? styles.selectedCard : ''}`}
94
+ onClick={() => {
95
+ setQuantity(pkg.threads);
96
+ setIsCustom(false);
97
+ }}
98
+ >
99
+ {pkg.popular && <span className={styles.popularBadge}>Most Popular</span>}
100
+ <div className={styles.threads}>{pkg.threads}</div>
101
+ <div className={styles.price}>Threads</div>
102
+ <div style={{ marginTop: 'auto', fontSize: '1.25rem', fontWeight: 'bold' }}>€{pkg.price}</div>
103
+ </div>
104
+ ))}
105
+ </div>
106
+
107
+ <div className={styles.customInput}>
108
+ <Button
109
+ variant="ghost"
110
+ fullWidth
111
+ onClick={() => setIsCustom(!isCustom)}
112
+ style={{ marginBottom: isCustom ? '16px' : '0' }}
113
+ >
114
+ {isCustom ? 'Hide Custom Amount' : 'Enter Custom Amount'}
115
+ </Button>
116
+
117
+ {isCustom && (
118
+ <Input
119
+ type="number"
120
+ label="Number of Threads"
121
+ value={quantity}
122
+ onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 0))}
123
+ min={1}
124
+ placeholder={`Approx. €${(quantity * tokenPrice).toFixed(2)}`}
125
+ fullWidth
126
+ />
127
+ )}
128
+ </div>
129
+ </div>
130
+ </Modal>
131
+ );
132
+ };