@visns-studio/visns-components 5.11.5 → 5.11.7

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,337 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Eye, Download, RefreshCw, ExternalLink } from 'lucide-react';
3
+ import { buildUrl, defaultProposalApiEndpoints } from '../../utils/urlBuilder';
4
+ import './ProposalTemplatePreview.css';
5
+
6
+ // Helper function to extract variables from sections
7
+ const extractVariablesFromSections = (sections) => {
8
+ const variables = new Set();
9
+ sections.forEach(section => {
10
+ if (section.variables && Array.isArray(section.variables)) {
11
+ section.variables.forEach(variable => variables.add(variable));
12
+ }
13
+ // Also extract from content using regex
14
+ if (section.content) {
15
+ const matches = section.content.match(/{{(\w+)}}/g);
16
+ if (matches) {
17
+ matches.forEach(match => {
18
+ const variable = match.replace(/[{}]/g, '');
19
+ variables.add(variable);
20
+ });
21
+ }
22
+ }
23
+ });
24
+ return Array.from(variables);
25
+ };
26
+
27
+ const ProposalTemplatePreview = ({
28
+ templateId,
29
+ templateData,
30
+ apiEndpoints = defaultProposalApiEndpoints
31
+ }) => {
32
+ const [previewData, setPreviewData] = useState(null);
33
+ const [isLoading, setIsLoading] = useState(false);
34
+ const [error, setError] = useState(null);
35
+ const [selectedBrandingProfile, setSelectedBrandingProfile] = useState('');
36
+ const [brandingProfiles, setBrandingProfiles] = useState([]);
37
+
38
+ // Helper function to replace template variables in URLs with templateId included
39
+ const buildUrlWithTemplate = (urlTemplate, params = {}) => {
40
+ return buildUrl(urlTemplate, { templateId, ...params });
41
+ };
42
+
43
+ useEffect(() => {
44
+ if (templateId) {
45
+ loadBrandingProfiles();
46
+ if (templateData) {
47
+ // Use template data if available
48
+ setPreviewData({
49
+ template: templateData,
50
+ sections: templateData.sections || [],
51
+ variables_used: extractVariablesFromSections(templateData.sections || [])
52
+ });
53
+ } else {
54
+ loadPreview();
55
+ }
56
+ }
57
+ }, [templateId, templateData]);
58
+
59
+ const loadBrandingProfiles = async () => {
60
+ try {
61
+ const response = await fetch(apiEndpoints.brandingProfiles, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
66
+ }
67
+ });
68
+ const data = await response.json();
69
+
70
+ // Handle different response formats
71
+ let profiles = [];
72
+ if (data && data.success && Array.isArray(data.data)) {
73
+ profiles = data.data;
74
+ } else if (Array.isArray(data)) {
75
+ profiles = data;
76
+ }
77
+
78
+ setBrandingProfiles(profiles);
79
+ if (profiles.length > 0) {
80
+ setSelectedBrandingProfile(profiles[0].id);
81
+ }
82
+ } catch (error) {
83
+ console.error('Error loading branding profiles:', error);
84
+ setBrandingProfiles([]);
85
+ }
86
+ };
87
+
88
+ const loadPreview = async () => {
89
+ if (!templateId) return;
90
+
91
+ try {
92
+ setIsLoading(true);
93
+ setError(null);
94
+
95
+ const response = await fetch(`${buildUrlWithTemplate(apiEndpoints.templatePreview)}?branding_profile_id=${selectedBrandingProfile}`);
96
+
97
+ if (!response.ok) {
98
+ throw new Error('Failed to load preview');
99
+ }
100
+
101
+ const data = await response.json();
102
+ if (data.success) {
103
+ // Ensure variables_used exists, fallback to client-side extraction
104
+ const responseData = {
105
+ ...data.data,
106
+ variables_used: data.data.variables_used || extractVariablesFromSections(data.data.sections || [])
107
+ };
108
+
109
+ setPreviewData(responseData);
110
+ } else {
111
+ throw new Error(data.message || 'Failed to load preview');
112
+ }
113
+ } catch (error) {
114
+ console.error('Error loading preview:', error);
115
+ setError(error.message);
116
+ } finally {
117
+ setIsLoading(false);
118
+ }
119
+ };
120
+
121
+ const handleBrandingChange = (profileId) => {
122
+ setSelectedBrandingProfile(profileId);
123
+ };
124
+
125
+ const handleRefreshPreview = () => {
126
+ loadPreview();
127
+ };
128
+
129
+ const handleDownloadPDF = async () => {
130
+ try {
131
+ const response = await fetch(`${buildUrlWithTemplate(apiEndpoints.templatePdf)}?branding_profile_id=${selectedBrandingProfile}`, {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
136
+ }
137
+ });
138
+
139
+ if (response.ok) {
140
+ const blob = await response.blob();
141
+ const url = window.URL.createObjectURL(blob);
142
+ const a = document.createElement('a');
143
+ a.href = url;
144
+ a.download = `proposal-template-${templateId}.pdf`;
145
+ document.body.appendChild(a);
146
+ a.click();
147
+ window.URL.revokeObjectURL(url);
148
+ document.body.removeChild(a);
149
+ } else {
150
+ throw new Error('Failed to generate PDF');
151
+ }
152
+ } catch (error) {
153
+ console.error('Error downloading PDF:', error);
154
+ alert('Failed to download PDF. Please try again.');
155
+ }
156
+ };
157
+
158
+ const handleOpenFullPreview = () => {
159
+ const url = `${buildUrlWithTemplate(apiEndpoints.templateFullPreview)}?branding_profile_id=${selectedBrandingProfile}&full=1`;
160
+ window.open(url, '_blank');
161
+ };
162
+
163
+ if (!templateId) {
164
+ return (
165
+ <div className="proposal-preview-no-template">
166
+ <Eye size={48} className="proposal-preview-no-template-icon" />
167
+ <h3 className="proposal-preview-no-template-title">No Template Selected</h3>
168
+ <p className="proposal-preview-no-template-text">Save the template first to see the preview</p>
169
+ </div>
170
+ );
171
+ }
172
+
173
+ return (
174
+ <div className="proposal-preview-container">
175
+ {/* Preview Controls */}
176
+ <div className="proposal-preview-controls">
177
+ <div className="proposal-preview-branding-section">
178
+ <div>
179
+ <label className="proposal-preview-branding-label">
180
+ Branding Profile
181
+ </label>
182
+ <select
183
+ value={selectedBrandingProfile}
184
+ onChange={(e) => handleBrandingChange(e.target.value)}
185
+ className="proposal-preview-branding-select"
186
+ >
187
+ <option value="">Default Branding</option>
188
+ {Array.isArray(brandingProfiles) && brandingProfiles.map(profile => (
189
+ <option key={profile.id} value={profile.id}>
190
+ {profile.name}
191
+ </option>
192
+ ))}
193
+ </select>
194
+ </div>
195
+ </div>
196
+
197
+ <div className="proposal-preview-actions">
198
+ <button
199
+ onClick={handleRefreshPreview}
200
+ disabled={isLoading}
201
+ className="proposal-preview-btn secondary"
202
+ >
203
+ <RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
204
+ Refresh
205
+ </button>
206
+
207
+ <button
208
+ onClick={handleOpenFullPreview}
209
+ className="proposal-preview-btn outline"
210
+ >
211
+ <ExternalLink size={16} />
212
+ Full Preview
213
+ </button>
214
+
215
+ <button
216
+ onClick={handleDownloadPDF}
217
+ className="proposal-preview-btn primary"
218
+ >
219
+ <Download size={16} />
220
+ Download PDF
221
+ </button>
222
+ </div>
223
+ </div>
224
+
225
+ {/* Preview Content */}
226
+ <div className="proposal-preview-content">
227
+ {isLoading ? (
228
+ <div className="proposal-preview-loading">
229
+ <div className="proposal-preview-loading-content">
230
+ <RefreshCw size={32} className="proposal-preview-loading-icon" />
231
+ <p className="proposal-preview-loading-text">Loading preview...</p>
232
+ </div>
233
+ </div>
234
+ ) : error ? (
235
+ <div className="proposal-preview-error">
236
+ <div className="proposal-preview-error-content">
237
+ <div className="proposal-preview-error-icon">⚠️</div>
238
+ <h3 className="proposal-preview-error-title">Preview Error</h3>
239
+ <p className="proposal-preview-error-message">{error}</p>
240
+ <button
241
+ onClick={handleRefreshPreview}
242
+ className="proposal-preview-btn primary"
243
+ >
244
+ Try Again
245
+ </button>
246
+ </div>
247
+ </div>
248
+ ) : previewData ? (
249
+ <div>
250
+ {/* Preview Header */}
251
+ <div className="proposal-preview-header">
252
+ <div className="proposal-preview-header-content">
253
+ <div className="proposal-preview-header-info">
254
+ <h3>Template Preview</h3>
255
+ <p className="proposal-preview-header-meta">
256
+ {previewData.sections?.length || 0} sections •
257
+ Variables: {previewData.variables_used?.length || 0}
258
+ </p>
259
+ </div>
260
+ <div className="proposal-preview-header-timestamp">
261
+ Last updated: {new Date().toLocaleString()}
262
+ </div>
263
+ </div>
264
+ </div>
265
+
266
+ {/* Preview Body */}
267
+ <div className="proposal-preview-body">
268
+ <div className="proposal-preview-prose">
269
+ {previewData.html ? (
270
+ <div dangerouslySetInnerHTML={{ __html: previewData.html }} />
271
+ ) : previewData.sections && previewData.sections.length > 0 ? (
272
+ <div>
273
+ {previewData.sections.map((section, index) => (
274
+ <div key={section.id || index} className="proposal-preview-section">
275
+ <h3 className="proposal-preview-section-title">
276
+ {section.title}
277
+ </h3>
278
+ <div
279
+ className="proposal-preview-section-content"
280
+ dangerouslySetInnerHTML={{
281
+ __html: section.content || '<em>No content</em>'
282
+ }}
283
+ />
284
+ </div>
285
+ ))}
286
+ </div>
287
+ ) : (
288
+ <div className="proposal-preview-empty">
289
+ No content to preview
290
+ </div>
291
+ )}
292
+ </div>
293
+ </div>
294
+
295
+ {/* Preview Footer */}
296
+ {previewData.sections && previewData.sections.length > 0 && (
297
+ <div className="proposal-preview-footer">
298
+ <div className="proposal-preview-footer-tags">
299
+ {previewData.sections.map((section, index) => (
300
+ <span
301
+ key={index}
302
+ className="proposal-preview-section-tag"
303
+ >
304
+ {section.title}
305
+ </span>
306
+ ))}
307
+ </div>
308
+ </div>
309
+ )}
310
+ </div>
311
+ ) : (
312
+ <div className="proposal-preview-no-template">
313
+ <Eye size={48} className="proposal-preview-no-template-icon" />
314
+ <h3 className="proposal-preview-no-template-title">No Preview Available</h3>
315
+ <p className="proposal-preview-no-template-text">Add some sections to see the preview</p>
316
+ </div>
317
+ )}
318
+ </div>
319
+
320
+ {/* Variables Summary */}
321
+ {previewData?.variables_used && previewData.variables_used.length > 0 && (
322
+ <div className="proposal-preview-variables">
323
+ <h4 className="proposal-preview-variables-title">Variables Used in This Template</h4>
324
+ <div className="proposal-preview-variables-list">
325
+ {previewData.variables_used.map((variable, index) => (
326
+ <span key={index} className="proposal-preview-variable-tag">
327
+ {`{{${variable}}}`}
328
+ </span>
329
+ ))}
330
+ </div>
331
+ </div>
332
+ )}
333
+ </div>
334
+ );
335
+ };
336
+
337
+ export default ProposalTemplatePreview;
@@ -0,0 +1,338 @@
1
+ /* Proposal Template Section Manager Styles */
2
+ .proposal-section-manager {
3
+ padding: 1rem;
4
+ background: #f8fafc;
5
+ min-height: 400px;
6
+ }
7
+
8
+ .section-item {
9
+ background: white;
10
+ border: 1px solid #e2e8f0;
11
+ border-radius: 8px;
12
+ margin-bottom: 0.5rem;
13
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
14
+ transition: all 0.2s ease-in-out;
15
+ overflow: hidden;
16
+ }
17
+
18
+ .section-item:hover {
19
+ box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.1);
20
+ border-color: #cbd5e1;
21
+ transform: translateY(-1px);
22
+ }
23
+
24
+ .section-header {
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: space-between;
28
+ padding: 0.75rem 1rem;
29
+ border-bottom: 1px solid #f1f5f9;
30
+ background: #fcfcfd;
31
+ }
32
+
33
+ .section-info {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 0.75rem;
37
+ flex: 1;
38
+ }
39
+
40
+ .drag-handle {
41
+ cursor: move;
42
+ padding: 0.5rem;
43
+ color: #9ca3af;
44
+ transition: color 0.2s;
45
+ }
46
+
47
+ .drag-handle:hover {
48
+ color: #6b7280;
49
+ }
50
+
51
+ .section-title {
52
+ font-weight: 600;
53
+ color: #1e293b;
54
+ margin: 0;
55
+ font-size: 0.95rem;
56
+ }
57
+
58
+ .section-type-badge {
59
+ padding: 0.2rem 0.5rem;
60
+ font-size: 0.7rem;
61
+ font-weight: 600;
62
+ background-color: #e0f2fe;
63
+ color: #0c4a6e;
64
+ border-radius: 4px;
65
+ text-transform: uppercase;
66
+ letter-spacing: 0.025em;
67
+ }
68
+
69
+ .section-dynamic-badge {
70
+ padding: 0.2rem 0.5rem;
71
+ font-size: 0.7rem;
72
+ font-weight: 600;
73
+ background-color: #d1fae5;
74
+ color: #065f46;
75
+ border-radius: 4px;
76
+ text-transform: uppercase;
77
+ letter-spacing: 0.025em;
78
+ }
79
+
80
+ .section-meta {
81
+ font-size: 0.8rem;
82
+ color: #64748b;
83
+ margin-top: 0.25rem;
84
+ font-weight: 500;
85
+ }
86
+
87
+ .section-actions {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.5rem;
91
+ }
92
+
93
+ .section-action-btn {
94
+ padding: 0.5rem;
95
+ color: #9ca3af;
96
+ background: none;
97
+ border: none;
98
+ border-radius: 0.375rem;
99
+ cursor: pointer;
100
+ transition: all 0.2s;
101
+ }
102
+
103
+ .section-action-btn:hover {
104
+ color: #6b7280;
105
+ background-color: #f3f4f6;
106
+ }
107
+
108
+ .section-action-btn.edit {
109
+ padding: 0.25rem 0.75rem;
110
+ color: #2563eb;
111
+ font-size: 0.875rem;
112
+ }
113
+
114
+ .section-action-btn.edit:hover {
115
+ color: #1d4ed8;
116
+ background-color: #eff6ff;
117
+ }
118
+
119
+ .section-action-btn.delete {
120
+ color: #dc2626;
121
+ }
122
+
123
+ .section-action-btn.delete:hover {
124
+ color: #b91c1c;
125
+ background-color: #fef2f2;
126
+ }
127
+
128
+ .section-content {
129
+ padding: 0.75rem 1rem;
130
+ max-height: 120px;
131
+ overflow: hidden;
132
+ background: #fafbfc;
133
+ border-top: 1px solid #f1f5f9;
134
+ }
135
+
136
+ .section-content-preview {
137
+ font-size: 0.75rem;
138
+ line-height: 1.3;
139
+ color: #475569;
140
+ position: relative;
141
+ }
142
+
143
+ /* Removed gradient overlay that was causing blurred text */
144
+
145
+ .section-content-preview h1,
146
+ .section-content-preview h2,
147
+ .section-content-preview h3,
148
+ .section-content-preview h4,
149
+ .section-content-preview h5,
150
+ .section-content-preview h6 {
151
+ font-size: 0.8rem !important;
152
+ line-height: 1.1 !important;
153
+ margin: 0 0 0.125rem 0 !important;
154
+ font-weight: 600;
155
+ color: #475569 !important;
156
+ }
157
+
158
+ .section-content-preview p {
159
+ font-size: 0.75rem !important;
160
+ line-height: 1.2 !important;
161
+ margin: 0 0 0.125rem 0 !important;
162
+ color: #475569 !important;
163
+ }
164
+
165
+ .section-content-preview ul,
166
+ .section-content-preview ol {
167
+ font-size: 0.75rem !important;
168
+ line-height: 1.2 !important;
169
+ margin: 0 0 0.125rem 1rem !important;
170
+ padding: 0;
171
+ color: #475569 !important;
172
+ }
173
+
174
+ .section-content-preview li {
175
+ margin: 0 !important;
176
+ padding: 0 !important;
177
+ font-size: 0.75rem !important;
178
+ line-height: 1.2 !important;
179
+ }
180
+
181
+ .section-no-content {
182
+ color: #9ca3af;
183
+ font-style: italic;
184
+ margin: 0;
185
+ padding: 0.5rem 0;
186
+ }
187
+
188
+ .add-section-header {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: space-between;
192
+ margin-bottom: 1.25rem;
193
+ padding: 1rem;
194
+ background: white;
195
+ border-radius: 8px;
196
+ border: 1px solid #e2e8f0;
197
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
198
+ }
199
+
200
+ .add-section-title {
201
+ font-size: 1.1rem;
202
+ font-weight: 600;
203
+ color: #1e293b;
204
+ margin: 0;
205
+ }
206
+
207
+ .add-section-description {
208
+ font-size: 0.85rem;
209
+ color: #64748b;
210
+ margin-top: 0.25rem;
211
+ font-weight: 500;
212
+ }
213
+
214
+ .add-section-btn {
215
+ display: inline-flex;
216
+ align-items: center;
217
+ gap: 0.5rem;
218
+ padding: 0.625rem 1.25rem;
219
+ font-size: 0.875rem;
220
+ font-weight: 600;
221
+ color: white;
222
+ background: #3cbf7d;
223
+ border: none;
224
+ border-radius: 5px;
225
+ cursor: pointer;
226
+ transition: all 0.2s ease-in-out;
227
+ box-shadow: 0 2px 4px rgba(60, 191, 125, 0.25);
228
+ }
229
+
230
+ .add-section-btn:hover {
231
+ background: #1b3933;
232
+ transform: translateY(-1px);
233
+ box-shadow: 0 4px 12px rgba(27, 57, 51, 0.35);
234
+ }
235
+
236
+ .empty-state {
237
+ text-align: center;
238
+ padding: 3rem;
239
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
240
+ border: 2px dashed #cbd5e1;
241
+ border-radius: 12px;
242
+ margin: 1rem 0;
243
+ }
244
+
245
+ .empty-state-icon {
246
+ margin: 0 auto 1rem;
247
+ color: #94a3b8;
248
+ opacity: 0.7;
249
+ }
250
+
251
+ .empty-state-title {
252
+ font-size: 1.2rem;
253
+ font-weight: 600;
254
+ color: #334155;
255
+ margin: 0 0 0.5rem 0;
256
+ }
257
+
258
+ .empty-state-description {
259
+ color: #64748b;
260
+ margin: 0 0 1.5rem 0;
261
+ font-size: 0.95rem;
262
+ }
263
+
264
+ .empty-state-btn {
265
+ display: inline-flex;
266
+ align-items: center;
267
+ gap: 0.5rem;
268
+ padding: 0.75rem 1.5rem;
269
+ font-size: 0.875rem;
270
+ font-weight: 600;
271
+ color: white;
272
+ background: #3cbf7d;
273
+ border: none;
274
+ border-radius: 5px;
275
+ cursor: pointer;
276
+ transition: all 0.2s ease-in-out;
277
+ box-shadow: 0 2px 4px rgba(60, 191, 125, 0.25);
278
+ }
279
+
280
+ .empty-state-btn:hover {
281
+ background: #1b3933;
282
+ transform: translateY(-1px);
283
+ box-shadow: 0 4px 12px rgba(27, 57, 51, 0.35);
284
+ }
285
+
286
+
287
+ /* Loading state */
288
+ .loading-spinner {
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: center;
292
+ padding: 3rem;
293
+ }
294
+
295
+ .spinner {
296
+ animation: spin 1s linear infinite;
297
+ border: 2px solid #f3f4f6;
298
+ border-top: 2px solid #2563eb;
299
+ border-radius: 50%;
300
+ width: 2rem;
301
+ height: 2rem;
302
+ }
303
+
304
+ @keyframes spin {
305
+ 0% { transform: rotate(0deg); }
306
+ 100% { transform: rotate(360deg); }
307
+ }
308
+
309
+ /* Sortable styling */
310
+ .sortable-helper {
311
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
312
+ transform: rotate(5deg);
313
+ z-index: 9999;
314
+ }
315
+
316
+ .sortable-ghost {
317
+ opacity: 0.4;
318
+ }
319
+
320
+ /* Responsive */
321
+ @media (max-width: 768px) {
322
+ .section-header {
323
+ flex-direction: column;
324
+ align-items: flex-start;
325
+ gap: 0.75rem;
326
+ }
327
+
328
+ .section-actions {
329
+ width: 100%;
330
+ justify-content: space-between;
331
+ }
332
+
333
+ .add-section-header {
334
+ flex-direction: column;
335
+ align-items: flex-start;
336
+ gap: 1rem;
337
+ }
338
+ }