form-components.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import {
  2. AffixedInput,
  3. Badge,
  4. Button,
  5. Card,
  6. CardContent,
  7. cn,
  8. DashboardFormComponent,
  9. Input,
  10. Select,
  11. SelectContent,
  12. SelectItem,
  13. SelectTrigger,
  14. SelectValue,
  15. Switch,
  16. Textarea,
  17. useLocalFormat,
  18. } from '@vendure/dashboard';
  19. import { Check, Lock, Mail, RefreshCw, Unlock, X } from 'lucide-react';
  20. import { KeyboardEvent, useEffect, useState } from 'react';
  21. import { useFormContext } from 'react-hook-form';
  22. export const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => {
  23. const [isOpen, setIsOpen] = useState(false);
  24. const { getFieldState } = useFormContext();
  25. const error = getFieldState(name).error;
  26. const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD'];
  27. return (
  28. <div className="space-y-2">
  29. <div className="flex items-center space-x-2">
  30. <Button
  31. type="button"
  32. variant="outline"
  33. size="icon"
  34. className={cn('w-8 h-8 border-2 border-gray-300 p-0', error && 'border-red-500')}
  35. style={{ backgroundColor: error ? 'transparent' : value || '#ffffff' }}
  36. onClick={() => setIsOpen(!isOpen)}
  37. />
  38. <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder="#ffffff" />
  39. </div>
  40. {isOpen && (
  41. <Card>
  42. <CardContent className="grid grid-cols-4 gap-2 p-2">
  43. {colors.map(color => (
  44. <Button
  45. key={color}
  46. type="button"
  47. variant="outline"
  48. size="icon"
  49. className="w-8 h-8 border-2 border-gray-300 hover:border-gray-500 p-0"
  50. style={{ backgroundColor: color }}
  51. onClick={() => {
  52. onChange(color);
  53. setIsOpen(false);
  54. }}
  55. />
  56. ))}
  57. </CardContent>
  58. </Card>
  59. )}
  60. </div>
  61. );
  62. };
  63. export const MarkdownEditorComponent: DashboardFormComponent = props => {
  64. const { getFieldState } = useFormContext();
  65. const fieldState = getFieldState(props.name);
  66. return (
  67. <Textarea
  68. className="font-mono"
  69. ref={props.ref}
  70. onBlur={props.onBlur}
  71. value={props.value}
  72. onChange={e => props.onChange(e.target.value)}
  73. disabled={props.disabled}
  74. />
  75. );
  76. };
  77. export const EmailInputComponent: DashboardFormComponent = ({ name, value, onChange, disabled }) => {
  78. const { getFieldState } = useFormContext();
  79. const isValid = getFieldState(name).invalid === false;
  80. return (
  81. <AffixedInput
  82. prefix={<Mail className="h-4 w-4 text-muted-foreground" />}
  83. suffix={
  84. value &&
  85. (isValid ? (
  86. <Check className="h-4 w-4 text-green-500" />
  87. ) : (
  88. <X className="h-4 w-4 text-red-500" />
  89. ))
  90. }
  91. value={value || ''}
  92. onChange={e => onChange(e.target.value)}
  93. disabled={disabled}
  94. placeholder="Enter email address"
  95. className="pl-10 pr-10"
  96. name={name}
  97. />
  98. );
  99. };
  100. export const MultiCurrencyInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
  101. const [currency, setCurrency] = useState('USD');
  102. const { formatCurrencyName } = useLocalFormat();
  103. const currencies = [
  104. { code: 'USD', symbol: '$', rate: 1 },
  105. { code: 'EUR', symbol: '€', rate: 0.85 },
  106. { code: 'GBP', symbol: '£', rate: 0.73 },
  107. { code: 'JPY', symbol: '¥', rate: 110 },
  108. ];
  109. const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];
  110. // Convert price based on exchange rate
  111. const displayValue = value ? (value * selectedCurrency.rate).toFixed(2) : '';
  112. const handleChange = (val: string) => {
  113. const numericValue = parseFloat(val) || 0;
  114. // Convert back to base currency (USD) for storage
  115. const baseValue = numericValue / selectedCurrency.rate;
  116. onChange(baseValue);
  117. };
  118. return (
  119. <div className="flex space-x-2">
  120. <Select value={currency} onValueChange={setCurrency} disabled={disabled}>
  121. <SelectTrigger className="w-24">
  122. <SelectValue>
  123. <div className="flex items-center gap-1">{currency}</div>
  124. </SelectValue>
  125. </SelectTrigger>
  126. <SelectContent>
  127. {currencies.map(curr => {
  128. return (
  129. <SelectItem key={curr.code} value={curr.code}>
  130. <div className="flex items-center gap-2">{formatCurrencyName(curr.code)}</div>
  131. </SelectItem>
  132. );
  133. })}
  134. </SelectContent>
  135. </Select>
  136. <AffixedInput
  137. prefix={selectedCurrency.symbol}
  138. value={displayValue}
  139. onChange={e => onChange(e.target.value)}
  140. disabled={disabled}
  141. placeholder="0.00"
  142. className="pl-8"
  143. name={name}
  144. />
  145. </div>
  146. );
  147. };
  148. export const TagsInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name, onBlur }) => {
  149. const [inputValue, setInputValue] = useState('');
  150. // Parse tags from string value (comma-separated)
  151. const tags: string[] = value ? value.split(',').filter(Boolean) : [];
  152. const addTag = (tag: string) => {
  153. const trimmedTag = tag.trim();
  154. if (trimmedTag && !tags.includes(trimmedTag)) {
  155. const newTags = [...tags, trimmedTag];
  156. onChange(newTags.join(','));
  157. }
  158. setInputValue('');
  159. };
  160. const removeTag = (tagToRemove: string) => {
  161. const newTags = tags.filter(tag => tag !== tagToRemove);
  162. onChange(newTags.join(','));
  163. };
  164. const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
  165. if (e.key === 'Enter' || e.key === ',') {
  166. e.preventDefault();
  167. addTag(inputValue);
  168. } else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0) {
  169. removeTag(tags[tags.length - 1]);
  170. }
  171. };
  172. return (
  173. <div className="space-y-2">
  174. {/* Tags Display */}
  175. <div className="flex flex-wrap gap-1">
  176. {tags.map((tag, index) => (
  177. <Badge key={index} variant="secondary" className="gap-1">
  178. {tag}
  179. <Button
  180. type="button"
  181. variant="ghost"
  182. size="icon"
  183. className="h-4 w-4 p-0 hover:bg-transparent"
  184. onClick={() => removeTag(tag)}
  185. disabled={disabled}
  186. >
  187. <X className="h-3 w-3" />
  188. </Button>
  189. </Badge>
  190. ))}
  191. </div>
  192. {/* Input */}
  193. <Input
  194. value={inputValue}
  195. onChange={e => setInputValue(e.target.value)}
  196. onKeyDown={handleKeyDown}
  197. onBlur={onBlur}
  198. disabled={disabled}
  199. placeholder="Type a tag and press Enter or comma"
  200. name={name}
  201. />
  202. </div>
  203. );
  204. };
  205. export const SlugInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
  206. const [autoGenerate, setAutoGenerate] = useState(!value);
  207. const [isGenerating, setIsGenerating] = useState(false);
  208. const { watch } = useFormContext();
  209. const nameValue = watch('translations.0.name');
  210. const generateSlug = (text: string) => {
  211. return text
  212. .toLowerCase()
  213. .replace(/[^a-z0-9 -]/g, '') // Remove special characters
  214. .replace(/\s+/g, '-') // Replace spaces with hyphens
  215. .replace(/-+/g, '-') // Replace multiple hyphens with single
  216. .trim('-'); // Remove leading/trailing hyphens
  217. };
  218. useEffect(() => {
  219. if (autoGenerate && nameValue) {
  220. const newSlug = generateSlug(nameValue);
  221. if (newSlug !== value) {
  222. onChange(newSlug);
  223. }
  224. }
  225. }, [nameValue, autoGenerate, onChange, value]);
  226. const handleManualGenerate = async () => {
  227. if (!nameValue) return;
  228. setIsGenerating(true);
  229. // Simulate API call for slug validation/generation
  230. await new Promise(resolve => setTimeout(resolve, 500));
  231. const newSlug = generateSlug(nameValue);
  232. onChange(newSlug);
  233. setIsGenerating(false);
  234. };
  235. return (
  236. <div className="space-y-2">
  237. <div className="flex items-center space-x-2">
  238. <Input
  239. value={value || ''}
  240. onChange={e => onChange(e.target.value)}
  241. disabled={disabled || autoGenerate}
  242. placeholder="product-slug"
  243. className="flex-1"
  244. name={name}
  245. />
  246. <Button
  247. type="button"
  248. variant="outline"
  249. size="icon"
  250. disabled={disabled || !nameValue || isGenerating}
  251. onClick={handleManualGenerate}
  252. >
  253. <RefreshCw className={`h-4 w-4 ${isGenerating ? 'animate-spin' : ''}`} />
  254. </Button>
  255. </div>
  256. <div className="flex items-center space-x-2">
  257. <Switch checked={autoGenerate} onCheckedChange={setAutoGenerate} disabled={disabled} />
  258. <div className="flex items-center space-x-1 text-sm text-muted-foreground">
  259. {autoGenerate ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
  260. <span>Auto-generate from name</span>
  261. </div>
  262. </div>
  263. </div>
  264. );
  265. };