Update skills: add website-creator, mql-developer, ecommerce-astro

Changes:
- Add FAL_KEY and GEMINI_API_KEY to .env.example
- Update picture-it to use ~/.config/opencode/.env (unified creds)
- Remove shodh-memory skill (no longer used)
- Remove alphaear-* skills (deprecated)
- Remove thai-frontend-dev skill (replaced by website-creator)
- Remove theme-factory skill
- Add mql-developer skill (MQL5 trading)
- Add ecommerce-astro skill (Astro e-commerce)
- Add website-creator skill (Next.js + Payload CMS)
- Update install script for new skills
This commit is contained in:
2026-04-16 17:40:27 +07:00
parent 5053ccdba2
commit b26c8199a5
562 changed files with 59030 additions and 37600 deletions

View File

@@ -0,0 +1,179 @@
import { useState } from 'react';
import StarRating from './StarRating';
interface ReviewFormProps {
productId: string;
onSubmit: (review: { rating: number; title: string; comment: string; images: File[] }) => void;
isSubmitting?: boolean;
}
export default function ReviewForm({ productId, onSubmit, isSubmitting = false }: ReviewFormProps) {
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [title, setTitle] = useState('');
const [comment, setComment] = useState('');
const [images, setImages] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length + images.length > 5) {
setErrors(prev => ({ ...prev, images: 'สูงสุด 5 รูปเท่านั้น' }));
return;
}
setImages(prev => [...prev, ...files]);
const newPreviews = files.map(file => URL.createObjectURL(file));
setImagePreviews(prev => [...prev, ...newPreviews]);
setErrors(prev => {
const { images: _, ...rest } = prev;
return rest;
});
};
const removeImage = (index: number) => {
setImages(prev => prev.filter((_, i) => i !== index));
setImagePreviews(prev => {
URL.revokeObjectURL(prev[index]);
return prev.filter((_, i) => i !== index);
});
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (rating === 0) newErrors.rating = 'กรุณาให้คะแนน';
if (!comment.trim()) newErrors.comment = 'กรุณาเขียนรีวิว';
if (comment.length < 10) newErrors.comment = 'รีวิวต้องมีอย่างน้อย 10 ตัวอักษร';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
await onSubmit({ rating, title, comment, images });
// Reset form
setRating(0);
setHoverRating(0);
setTitle('');
setComment('');
setImages([]);
setImagePreviews([]);
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-xl border p-6 space-y-6">
<h3 className="font-medium text-lg"></h3>
{/* Rating */}
<div>
<label className="block text-sm font-medium mb-2"> *</label>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 hover:scale-110 transition-transform"
>
<StarRating
rating={hoverRating || rating}
size={28}
/>
</button>
))}
{rating > 0 && (
<span className="ml-2 text-sm text-gray-600">
{rating === 1 ? 'ไม่พอใจมาก' :
rating === 2 ? 'ไม่พอใจ' :
rating === 3 ? 'พอใช้' :
rating === 4 ? 'พอใจ' : 'พอใจมาก'}
</span>
)}
</div>
{errors.rating && <p className="text-red-500 text-sm mt-1">{errors.rating}</p>}
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium mb-1"> ()</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="สรุปประสบการณ์ของคุณ"
className="w-full border rounded-lg px-3 py-2"
maxLength={100}
/>
</div>
{/* Comment */}
<div>
<label className="block text-sm font-medium mb-1"> *</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="แชร์ประสบการณ์ที่ใช้สินค้าของคุณ..."
className={`w-full border rounded-lg px-3 py-2 h-32 resize-none ${errors.comment ? 'border-red-500' : ''}`}
/>
<div className="flex justify-between mt-1">
{errors.comment && <p className="text-red-500 text-sm">{errors.comment}</p>}
<span className="text-xs text-gray-400 ml-auto">{comment.length}/1000</span>
</div>
</div>
{/* Images */}
<div>
<label className="block text-sm font-medium mb-2"> ()</label>
<div className="flex flex-wrap gap-2">
{imagePreviews.map((preview, i) => (
<div key={i} className="relative w-20 h-20">
<img
src={preview}
alt={`รูปที่ ${i + 1}`}
className="w-full h-full object-cover rounded-lg"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -top-2 -right-2 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center text-xs"
>
×
</button>
</div>
))}
{images.length < 5 && (
<label className="w-20 h-20 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-colors">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-xs text-gray-400 mt-1">{images.length}/5</span>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageChange}
className="hidden"
/>
</label>
)}
</div>
{errors.images && <p className="text-red-500 text-sm mt-1">{errors.images}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'กำลังส่งรีวิว...' : 'ส่งรีวิว'}
</button>
</form>
);
}