profile page ui complated
This commit is contained in:
20
src/features/modals/siModal/utils/pricing.ts
Normal file
20
src/features/modals/siModal/utils/pricing.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PricingConfig } from './tyeps';
|
||||
|
||||
const DEFAULT_PRICING: PricingConfig = {
|
||||
pricePerWord: 4, // 4 so'm per word
|
||||
minimumPayment: 10000, // 10 000 so'm minimum
|
||||
};
|
||||
|
||||
export function calculatePrice(
|
||||
wordCount: number,
|
||||
config: PricingConfig = DEFAULT_PRICING,
|
||||
): number {
|
||||
const calculated = wordCount * config.pricePerWord;
|
||||
return Math.max(calculated, config.minimumPayment);
|
||||
}
|
||||
|
||||
export function formatPrice(amount: number): string {
|
||||
return new Intl.NumberFormat('uz-UZ').format(amount) + " so'm";
|
||||
}
|
||||
|
||||
export { DEFAULT_PRICING };
|
||||
24
src/features/modals/siModal/utils/tyeps.ts
Normal file
24
src/features/modals/siModal/utils/tyeps.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface UploadedFile {
|
||||
file: File;
|
||||
name: string;
|
||||
sizeKB: number;
|
||||
wordCount: number;
|
||||
status: 'uploading' | 'done' | 'error';
|
||||
}
|
||||
|
||||
export interface PricingConfig {
|
||||
pricePerWord: number; // in so'm
|
||||
minimumPayment: number; // in so'm
|
||||
}
|
||||
|
||||
export interface FileUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (documentName: string, file: File, wordCount: number) => void;
|
||||
pricing?: PricingConfig;
|
||||
}
|
||||
|
||||
export interface WordCountResult {
|
||||
count: number;
|
||||
error?: string;
|
||||
}
|
||||
128
src/features/modals/siModal/utils/useFileUpload.ts
Normal file
128
src/features/modals/siModal/utils/useFileUpload.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { UploadedFile } from './tyeps';
|
||||
import { countWordsFromFile, SUPPORTED_EXTENSIONS } from './wordCount';
|
||||
|
||||
interface UseFileUploadReturn {
|
||||
documentName: string;
|
||||
setDocumentName: (name: string) => void;
|
||||
uploadedFile: UploadedFile | null;
|
||||
isDragging: boolean;
|
||||
isProcessing: boolean;
|
||||
error: string | null;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
handleFileSelect: (file: File) => Promise<void>;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleDragOver: (e: React.DragEvent) => void;
|
||||
handleDragLeave: () => void;
|
||||
handleRemoveFile: () => void;
|
||||
openFilePicker: () => void;
|
||||
}
|
||||
|
||||
export function useFileUpload(): UseFileUploadReturn {
|
||||
const [documentName, setDocumentName] = useState('');
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedFile | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
|
||||
return `Unsupported file type. Allowed: ${SUPPORTED_EXTENSIONS.join(', ')}`;
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
return 'File size must be less than 50 MB';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic UI: show file immediately
|
||||
const optimistic: UploadedFile = {
|
||||
file,
|
||||
name: file.name,
|
||||
sizeKB: Math.round(file.size / 1024),
|
||||
wordCount: 0,
|
||||
status: 'uploading',
|
||||
};
|
||||
setUploadedFile(optimistic);
|
||||
setIsProcessing(true);
|
||||
|
||||
// Auto-fill document name if empty
|
||||
setDocumentName((prev) =>
|
||||
prev.trim() === '' ? file.name.replace(/\.[^/.]+$/, '') : prev,
|
||||
);
|
||||
|
||||
// Count words on the frontend (no round-trip needed)
|
||||
const result = await countWordsFromFile(file);
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
setUploadedFile({ ...optimistic, status: 'error', wordCount: 0 });
|
||||
} else {
|
||||
setUploadedFile({
|
||||
...optimistic,
|
||||
status: 'done',
|
||||
wordCount: result.count,
|
||||
});
|
||||
}
|
||||
|
||||
setIsProcessing(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileSelect(file);
|
||||
},
|
||||
[handleFileSelect],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = useCallback(() => {
|
||||
setUploadedFile(null);
|
||||
setError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}, []);
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documentName,
|
||||
setDocumentName,
|
||||
uploadedFile,
|
||||
isDragging,
|
||||
isProcessing,
|
||||
error,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleRemoveFile,
|
||||
openFilePicker,
|
||||
};
|
||||
}
|
||||
93
src/features/modals/siModal/utils/wordCount.ts
Normal file
93
src/features/modals/siModal/utils/wordCount.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { WordCountResult } from './tyeps';
|
||||
|
||||
// ── Plain text ──────────────────────────────────────────────────────────────
|
||||
function countWordsFromText(text: string): number {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0).length;
|
||||
}
|
||||
|
||||
// ── .txt ────────────────────────────────────────────────────────────────────
|
||||
async function countFromTxt(file: File): Promise<WordCountResult> {
|
||||
const text = await file.text();
|
||||
return { count: countWordsFromText(text) };
|
||||
}
|
||||
|
||||
// ── .docx (reads raw XML, strips tags) ─────────────────────────────────────
|
||||
async function countFromDocx(file: File): Promise<WordCountResult> {
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const xmlFile = zip.file('word/document.xml');
|
||||
|
||||
if (!xmlFile) {
|
||||
return { count: 0, error: 'Could not read .docx content' };
|
||||
}
|
||||
|
||||
const xml = await xmlFile.async('string');
|
||||
const text = xml
|
||||
.replace(/<[^>]+>/g, ' ') // strip XML tags
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return { count: countWordsFromText(text) };
|
||||
} catch {
|
||||
return { count: 0, error: 'Failed to parse .docx file' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── .pdf (reads raw text layer; won't work for scanned PDFs) ───────────────
|
||||
async function countFromPdf(file: File): Promise<WordCountResult> {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const raw = new TextDecoder('latin1').decode(bytes);
|
||||
|
||||
// Extract text between BT/ET stream operators
|
||||
const textMatches = raw.match(/BT[\s\S]*?ET/g) ?? [];
|
||||
const words: string[] = [];
|
||||
|
||||
for (const block of textMatches) {
|
||||
// Match Tj / TJ operators
|
||||
const strings = block.match(/\(([^)]*)\)\s*Tj|\[([^\]]*)\]\s*TJ/g) ?? [];
|
||||
for (const s of strings) {
|
||||
const inner = s.replace(/\(|\)\s*Tj|\[|\]\s*TJ/g, '');
|
||||
words.push(...inner.split(/\s+/).filter((w) => w.length > 0));
|
||||
}
|
||||
}
|
||||
|
||||
return { count: words.length };
|
||||
} catch {
|
||||
return { count: 0, error: 'Failed to parse .pdf file' };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
export async function countWordsFromFile(file: File): Promise<WordCountResult> {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case 'txt':
|
||||
return countFromTxt(file);
|
||||
case 'docx':
|
||||
return countFromDocx(file);
|
||||
case 'pdf':
|
||||
return countFromPdf(file);
|
||||
default:
|
||||
// Fallback: try reading as text
|
||||
try {
|
||||
const text = await file.text();
|
||||
return { count: countWordsFromText(text) };
|
||||
} catch {
|
||||
return { count: 0, error: `Unsupported file type: .${ext}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const SUPPORTED_EXTENSIONS = ['.txt', '.docx', '.pdf'];
|
||||
export const SUPPORTED_MIME_TYPES = [
|
||||
'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/pdf',
|
||||
];
|
||||
Reference in New Issue
Block a user