create-stitchkit 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 (174) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/dist/cli.js +230 -0
  5. package/package.json +50 -0
  6. package/template/README.md +69 -0
  7. package/template/_env +11 -0
  8. package/template/_env.example +12 -0
  9. package/template/_gitignore +10 -0
  10. package/template/biome.json +43 -0
  11. package/template/bun.lock +1372 -0
  12. package/template/e2e/starter.spec.ts +237 -0
  13. package/template/ecosystem.config.cjs +28 -0
  14. package/template/ecosystem.dev.config.cjs +29 -0
  15. package/template/package.json +55 -0
  16. package/template/packages/backend/package.json +31 -0
  17. package/template/packages/backend/src/cli.ts +12 -0
  18. package/template/packages/backend/src/domain/errors.ts +9 -0
  19. package/template/packages/backend/src/domain/repository/github-cache.test.ts +72 -0
  20. package/template/packages/backend/src/domain/repository/github-cache.ts +205 -0
  21. package/template/packages/backend/src/index.ts +56 -0
  22. package/template/packages/backend/src/lib/db.ts +7 -0
  23. package/template/packages/backend/src/surface.ts +13 -0
  24. package/template/packages/backend/src/tools.ts +21 -0
  25. package/template/packages/backend/src/transport/errors.ts +28 -0
  26. package/template/packages/backend/src/transport/repository-service.ts +18 -0
  27. package/template/packages/backend/tsconfig.json +8 -0
  28. package/template/packages/config/package.json +22 -0
  29. package/template/packages/config/src/server.ts +24 -0
  30. package/template/packages/config/tsconfig.json +5 -0
  31. package/template/packages/db/migrations/20260808000000_init/migration.sql +16 -0
  32. package/template/packages/db/migrations/20260808170000_repository_visibility/migration.sql +4 -0
  33. package/template/packages/db/migrations/migration_lock.toml +1 -0
  34. package/template/packages/db/package.json +29 -0
  35. package/template/packages/db/prisma.config.ts +8 -0
  36. package/template/packages/db/schema.prisma +31 -0
  37. package/template/packages/db/tsconfig.json +5 -0
  38. package/template/packages/frontend/messages/en.json +15 -0
  39. package/template/packages/frontend/messages/ru.json +15 -0
  40. package/template/packages/frontend/next.config.ts +18 -0
  41. package/template/packages/frontend/package.json +62 -0
  42. package/template/packages/frontend/postcss.config.mjs +1 -0
  43. package/template/packages/frontend/public/ASSETS.md +6 -0
  44. package/template/packages/frontend/public/favicon/mascot-stitch.png +0 -0
  45. package/template/packages/frontend/public/icons/biome.svg +1 -0
  46. package/template/packages/frontend/public/icons/bun.svg +19 -0
  47. package/template/packages/frontend/public/icons/next.svg +1 -0
  48. package/template/packages/frontend/public/icons/postgresql.svg +1 -0
  49. package/template/packages/frontend/public/icons/prisma.svg +6 -0
  50. package/template/packages/frontend/public/icons/react-query.svg +9 -0
  51. package/template/packages/frontend/public/icons/react.svg +1 -0
  52. package/template/packages/frontend/public/icons/tailwind.svg +12 -0
  53. package/template/packages/frontend/public/icons/tsconfig.svg +1 -0
  54. package/template/packages/frontend/public/icons/typescript.svg +7 -0
  55. package/template/packages/frontend/public/icons/zod.svg +1 -0
  56. package/template/packages/frontend/public/mascot-stitch.png +0 -0
  57. package/template/packages/frontend/public/theme-dark.svg +8 -0
  58. package/template/packages/frontend/public/theme-light.svg +8 -0
  59. package/template/packages/frontend/src/app/[locale]/error.tsx +19 -0
  60. package/template/packages/frontend/src/app/[locale]/layout.tsx +59 -0
  61. package/template/packages/frontend/src/app/[locale]/loading.tsx +14 -0
  62. package/template/packages/frontend/src/app/[locale]/page.tsx +44 -0
  63. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
  64. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +40 -0
  65. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/account-showcase.tsx +112 -0
  66. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/admin-showcase.tsx +101 -0
  67. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-navigation.tsx +56 -0
  68. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-shell.tsx +51 -0
  69. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/code-foundation.tsx +35 -0
  70. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/components-story.tsx +54 -0
  71. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/data-story.tsx +126 -0
  72. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/image-preloader.tsx +8 -0
  73. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/index.ts +3 -0
  74. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-sections.tsx +269 -0
  75. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +159 -0
  76. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/motion-story.tsx +78 -0
  77. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/overlays-story.tsx +134 -0
  78. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/payment-showcase.tsx +82 -0
  79. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/presentation-story.tsx +68 -0
  80. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/primitives-story.tsx +267 -0
  81. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/story-page.tsx +14 -0
  82. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story-client.tsx +208 -0
  83. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story.tsx +13 -0
  84. package/template/packages/frontend/src/app/[locale]/ui/layout.tsx +6 -0
  85. package/template/packages/frontend/src/app/[locale]/ui/page.tsx +10 -0
  86. package/template/packages/frontend/src/app/api/og/[locale]/[page]/route.tsx +53 -0
  87. package/template/packages/frontend/src/app/globals.css +210 -0
  88. package/template/packages/frontend/src/app/robots.ts +9 -0
  89. package/template/packages/frontend/src/app/sitemap.ts +22 -0
  90. package/template/packages/frontend/src/components/brand-mark.tsx +22 -0
  91. package/template/packages/frontend/src/components/repository-summary.tsx +107 -0
  92. package/template/packages/frontend/src/components/system-controls.tsx +92 -0
  93. package/template/packages/frontend/src/components/ui/adaptive-modal.tsx +382 -0
  94. package/template/packages/frontend/src/components/ui/animated-counter.tsx +80 -0
  95. package/template/packages/frontend/src/components/ui/avatar.tsx +46 -0
  96. package/template/packages/frontend/src/components/ui/badge.tsx +94 -0
  97. package/template/packages/frontend/src/components/ui/button-variants.ts +24 -0
  98. package/template/packages/frontend/src/components/ui/button.tsx +81 -0
  99. package/template/packages/frontend/src/components/ui/card-deck.tsx +73 -0
  100. package/template/packages/frontend/src/components/ui/card.tsx +81 -0
  101. package/template/packages/frontend/src/components/ui/charts/area-chart.tsx +167 -0
  102. package/template/packages/frontend/src/components/ui/charts/index.ts +7 -0
  103. package/template/packages/frontend/src/components/ui/checkbox.tsx +133 -0
  104. package/template/packages/frontend/src/components/ui/confirmation-modal.tsx +129 -0
  105. package/template/packages/frontend/src/components/ui/copyable-text.tsx +85 -0
  106. package/template/packages/frontend/src/components/ui/drawer.tsx +136 -0
  107. package/template/packages/frontend/src/components/ui/dropdown-menu.tsx +185 -0
  108. package/template/packages/frontend/src/components/ui/field.tsx +28 -0
  109. package/template/packages/frontend/src/components/ui/hand-drawn-icons.tsx +41 -0
  110. package/template/packages/frontend/src/components/ui/index.ts +36 -0
  111. package/template/packages/frontend/src/components/ui/input.tsx +24 -0
  112. package/template/packages/frontend/src/components/ui/label.tsx +18 -0
  113. package/template/packages/frontend/src/components/ui/otp-input.tsx +140 -0
  114. package/template/packages/frontend/src/components/ui/select.tsx +240 -0
  115. package/template/packages/frontend/src/components/ui/sidebar.tsx +343 -0
  116. package/template/packages/frontend/src/components/ui/skeleton.tsx +8 -0
  117. package/template/packages/frontend/src/components/ui/slider.tsx +31 -0
  118. package/template/packages/frontend/src/components/ui/slot-drum.tsx +50 -0
  119. package/template/packages/frontend/src/components/ui/spinner.tsx +30 -0
  120. package/template/packages/frontend/src/components/ui/switch.tsx +31 -0
  121. package/template/packages/frontend/src/components/ui/table.tsx +106 -0
  122. package/template/packages/frontend/src/components/ui/tabs.tsx +196 -0
  123. package/template/packages/frontend/src/components/ui/textarea.tsx +21 -0
  124. package/template/packages/frontend/src/components/ui/toaster.tsx +72 -0
  125. package/template/packages/frontend/src/components/ui/tooltip.tsx +31 -0
  126. package/template/packages/frontend/src/env.ts +21 -0
  127. package/template/packages/frontend/src/features/data-table/components/column-helpers.tsx +136 -0
  128. package/template/packages/frontend/src/features/data-table/components/data-table.tsx +303 -0
  129. package/template/packages/frontend/src/features/data-table/components/inline-stat.tsx +113 -0
  130. package/template/packages/frontend/src/features/data-table/components/sortable-header.tsx +33 -0
  131. package/template/packages/frontend/src/features/data-table/components/table-error.tsx +24 -0
  132. package/template/packages/frontend/src/features/data-table/components/table-filters.tsx +82 -0
  133. package/template/packages/frontend/src/features/data-table/hooks/useTableState.ts +111 -0
  134. package/template/packages/frontend/src/features/data-table/index.ts +26 -0
  135. package/template/packages/frontend/src/features/data-table/table-features.ts +26 -0
  136. package/template/packages/frontend/src/hooks/index.ts +3 -0
  137. package/template/packages/frontend/src/hooks/useInfiniteScroll.ts +67 -0
  138. package/template/packages/frontend/src/hooks/useIsTouchDevice.ts +25 -0
  139. package/template/packages/frontend/src/hooks/useMediaQuery.ts +30 -0
  140. package/template/packages/frontend/src/i18n/locales.ts +6 -0
  141. package/template/packages/frontend/src/i18n/navigation.ts +4 -0
  142. package/template/packages/frontend/src/i18n/request.ts +9 -0
  143. package/template/packages/frontend/src/i18n/routing.ts +8 -0
  144. package/template/packages/frontend/src/lib/api/client.ts +20 -0
  145. package/template/packages/frontend/src/lib/api/queries.ts +11 -0
  146. package/template/packages/frontend/src/lib/query-client.ts +20 -0
  147. package/template/packages/frontend/src/lib/realtime/repository.ts +21 -0
  148. package/template/packages/frontend/src/lib/seo/metadata.ts +50 -0
  149. package/template/packages/frontend/src/lib/seo/pages.test.ts +25 -0
  150. package/template/packages/frontend/src/lib/seo/pages.ts +107 -0
  151. package/template/packages/frontend/src/lib/utils/cn.ts +6 -0
  152. package/template/packages/frontend/src/lib/utils/format.ts +25 -0
  153. package/template/packages/frontend/src/lib/utils/index.ts +2 -0
  154. package/template/packages/frontend/src/providers/index.tsx +18 -0
  155. package/template/packages/frontend/src/providers/react-query.tsx +9 -0
  156. package/template/packages/frontend/src/providers/realtime.tsx +16 -0
  157. package/template/packages/frontend/src/proxy.ts +6 -0
  158. package/template/packages/frontend/src/theme/config.ts +20 -0
  159. package/template/packages/frontend/src/theme/transition.ts +54 -0
  160. package/template/packages/frontend/tsconfig.json +13 -0
  161. package/template/packages/shared/package.json +23 -0
  162. package/template/packages/shared/src/contracts/repository.ts +28 -0
  163. package/template/packages/shared/src/events/repository.ts +9 -0
  164. package/template/packages/shared/src/index.ts +3 -0
  165. package/template/packages/shared/src/schemas/repository.test.ts +36 -0
  166. package/template/packages/shared/src/schemas/repository.ts +28 -0
  167. package/template/packages/shared/tsconfig.json +5 -0
  168. package/template/playwright.config.ts +15 -0
  169. package/template/scripts/check-authored.ts +81 -0
  170. package/template/scripts/dev.ts +23 -0
  171. package/template/scripts/local-env.ts +12 -0
  172. package/template/scripts/runtime-smoke.ts +67 -0
  173. package/template/scripts/tooling-env.ts +15 -0
  174. package/template/tsconfig.json +13 -0
@@ -0,0 +1,382 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * AdaptiveModal - Адаптивный модальный компонент
5
+ *
6
+ * Mobile: Bottom Sheet с drag-to-dismiss / Fullscreen / Modal
7
+ * Desktop: Center Modal с fade анимацией
8
+ *
9
+ * Powered by Radix UI Dialog & Motion for A11y and Animations
10
+ *
11
+ * ⚠️ ВАЖНО ДЛЯ ИСПОЛЬЗОВАНИЯ:
12
+ * Чтобы exit анимация работала, НЕ оборачивай в условный рендеринг:
13
+ * ❌ {isOpen && <AdaptiveModal />} // Ломает exit анимацию
14
+ * ✅ <AdaptiveModal isOpen={isOpen} /> // Правильно
15
+ *
16
+ * Компонент должен оставаться в DOM, управляй через проп isOpen
17
+ */
18
+
19
+ import * as DialogPrimitive from '@radix-ui/react-dialog';
20
+ import { IconX } from '@tabler/icons-react';
21
+ import {
22
+ AnimatePresence,
23
+ animate,
24
+ motion,
25
+ type PanInfo,
26
+ type Transition,
27
+ useDragControls,
28
+ useMotionValue,
29
+ useTransform,
30
+ } from 'framer-motion';
31
+ import { type ReactNode, useEffect, useRef, useState } from 'react';
32
+ import { useMediaQuery } from '@/hooks/useMediaQuery';
33
+ import { cn } from '@/lib/utils';
34
+ import { Button } from '.';
35
+
36
+ // ==========================================================================
37
+ // Types
38
+ // ==========================================================================
39
+
40
+ export interface AdaptiveModalProps {
41
+ isOpen: boolean;
42
+ onClose: () => void;
43
+ children: ReactNode;
44
+ title?: string;
45
+ showDragIndicator?: boolean;
46
+ desktopWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
47
+ className?: string;
48
+ mobileVariant?: 'modal' | 'sheet' | 'fullscreen';
49
+ footer?: ReactNode;
50
+ /** Закрывать по клику вне модалки (по умолчанию true, работает для desktop, mobile modal и sheet, не применяется для fullscreen) */
51
+ closeOnOutsideClick?: boolean;
52
+ }
53
+
54
+ // ==========================================================================
55
+ // Constants
56
+ // ==========================================================================
57
+
58
+ const OPEN_TRANSITION: Transition = {
59
+ type: 'tween',
60
+ duration: 0.4,
61
+ ease: [0.16, 1, 0.3, 1],
62
+ };
63
+ const CLOSE_TRANSITION: Transition = {
64
+ type: 'tween',
65
+ duration: 0.3,
66
+ ease: [0.32, 0, 0.67, 0],
67
+ };
68
+ const SNAP_BACK_TRANSITION: Transition = { type: 'spring', stiffness: 400, damping: 35 };
69
+ const BACKDROP_TRANSITION: Transition = { duration: 0.3 };
70
+ const MODAL_OPEN_TRANSITION: Transition = {
71
+ type: 'tween',
72
+ duration: 0.3,
73
+ ease: [0.16, 1, 0.3, 1],
74
+ };
75
+ const MODAL_CLOSE_TRANSITION: Transition = {
76
+ type: 'tween',
77
+ duration: 0.2,
78
+ ease: [0.32, 0, 0.67, 0],
79
+ };
80
+
81
+ const ANIMATION_CONFIGS = {
82
+ open: OPEN_TRANSITION,
83
+ close: CLOSE_TRANSITION,
84
+ snapBack: SNAP_BACK_TRANSITION,
85
+ backdrop: BACKDROP_TRANSITION,
86
+ modal: {
87
+ open: MODAL_OPEN_TRANSITION,
88
+ close: MODAL_CLOSE_TRANSITION,
89
+ },
90
+ };
91
+
92
+ const DRAG_THRESHOLD = 0.35;
93
+ const VELOCITY_THRESHOLD = 200;
94
+
95
+ const DESKTOP_WIDTHS = {
96
+ sm: 'max-w-sm',
97
+ md: 'max-w-md',
98
+ lg: 'max-w-lg',
99
+ xl: 'max-w-xl',
100
+ '2xl': 'max-w-2xl',
101
+ '3xl': 'max-w-3xl',
102
+ '4xl': 'max-w-4xl',
103
+ };
104
+
105
+ // ==========================================================================
106
+ // Internal Components
107
+ // ==========================================================================
108
+
109
+ interface ModalLayoutProps {
110
+ title?: string;
111
+ children: ReactNode;
112
+ footer?: ReactNode;
113
+ className?: string;
114
+ headerClassName?: string;
115
+ footerClassName?: string;
116
+ showCloseButton?: boolean;
117
+ }
118
+
119
+ const ModalLayout = ({
120
+ title,
121
+ children,
122
+ footer,
123
+ className,
124
+ headerClassName,
125
+ footerClassName,
126
+ showCloseButton = true,
127
+ }: ModalLayoutProps) => (
128
+ <div className={cn('flex min-h-0 flex-col overflow-hidden bg-background', className)}>
129
+ {/* Header */}
130
+ {(title || showCloseButton) && (
131
+ <div
132
+ className={cn(
133
+ 'flex shrink-0 items-center justify-between px-4 pt-4 pb-2',
134
+ headerClassName,
135
+ )}
136
+ >
137
+ <DialogPrimitive.Title asChild>
138
+ {title ? (
139
+ <h3 className='text-lg font-medium font-display'>{title}</h3>
140
+ ) : (
141
+ <span className='hidden'>Modal</span> // A11y requirement
142
+ )}
143
+ </DialogPrimitive.Title>
144
+ {showCloseButton && (
145
+ <DialogPrimitive.Close asChild>
146
+ <Button variant='ghost' size='icon' className='ml-auto' aria-label='Close dialog'>
147
+ <IconX size={20} stroke={1.5} />
148
+ </Button>
149
+ </DialogPrimitive.Close>
150
+ )}
151
+ </div>
152
+ )}
153
+
154
+ {/* Content */}
155
+ <div className='min-h-0 flex-1 overflow-y-auto'>{children}</div>
156
+
157
+ {/* Footer */}
158
+ {footer && <div className={cn('shrink-0 px-4 pb-4', footerClassName)}>{footer}</div>}
159
+ </div>
160
+ );
161
+
162
+ // ==========================================================================
163
+ // Main Component
164
+ // ==========================================================================
165
+
166
+ export function AdaptiveModal(props: AdaptiveModalProps) {
167
+ const isDesktop = useMediaQuery('(min-width: 768px)');
168
+ const [mounted, setMounted] = useState(false);
169
+
170
+ useEffect(() => {
171
+ setMounted(true);
172
+ }, []);
173
+
174
+ if (!mounted) return null;
175
+
176
+ const { mobileVariant = 'modal' } = props;
177
+ const isSheet = !isDesktop && mobileVariant === 'sheet';
178
+
179
+ return (
180
+ <DialogPrimitive.Root open={props.isOpen} onOpenChange={props.onClose}>
181
+ <AnimatePresence>
182
+ {props.isOpen && (
183
+ <DialogPrimitive.Portal forceMount>
184
+ {isSheet ? (
185
+ <MobileBottomSheet {...props} />
186
+ ) : (
187
+ <>
188
+ {/* Общий Overlay для desktop/mobile modal/fullscreen */}
189
+ <DialogPrimitive.Overlay asChild>
190
+ <motion.div
191
+ className='fixed inset-0 z-60 bg-overlay backdrop-blur-sm'
192
+ initial={{ opacity: 0 }}
193
+ animate={{ opacity: 1 }}
194
+ exit={{ opacity: 0 }}
195
+ transition={ANIMATION_CONFIGS.backdrop}
196
+ />
197
+ </DialogPrimitive.Overlay>
198
+
199
+ <UnifiedContent {...props} isDesktop={isDesktop} />
200
+ </>
201
+ )}
202
+ </DialogPrimitive.Portal>
203
+ )}
204
+ </AnimatePresence>
205
+ </DialogPrimitive.Root>
206
+ );
207
+ }
208
+
209
+ // ==========================================================================
210
+ // Unified Content (Desktop + Mobile Modal + Fullscreen)
211
+ // ==========================================================================
212
+
213
+ interface UnifiedContentProps extends AdaptiveModalProps {
214
+ isDesktop: boolean;
215
+ }
216
+
217
+ function UnifiedContent({ isDesktop, ...props }: UnifiedContentProps) {
218
+ const { mobileVariant = 'modal', closeOnOutsideClick = true } = props;
219
+ const isFullscreen = !isDesktop && mobileVariant === 'fullscreen';
220
+
221
+ return (
222
+ <DialogPrimitive.Content
223
+ asChild
224
+ aria-describedby={undefined}
225
+ onInteractOutside={(event) => {
226
+ if (!closeOnOutsideClick) event.preventDefault();
227
+ }}
228
+ >
229
+ <div
230
+ className={cn(
231
+ 'fixed inset-0 z-70 flex',
232
+ !isFullscreen && 'items-center justify-center p-4',
233
+ )}
234
+ >
235
+ <motion.div
236
+ className={cn(
237
+ 'pointer-events-auto flex flex-col overflow-hidden bg-background shadow-lg outline-none',
238
+ !isFullscreen && 'rounded-2xl border border-border',
239
+ isDesktop && `w-full ${DESKTOP_WIDTHS[props.desktopWidth || 'md']}`,
240
+ !isDesktop && !isFullscreen && 'w-full max-w-sm',
241
+ isFullscreen && 'h-full w-full',
242
+ !isFullscreen && 'max-h-[85vh]',
243
+ props.className,
244
+ )}
245
+ initial={{ opacity: 0, scale: 0.95 }}
246
+ animate={{
247
+ opacity: 1,
248
+ scale: 1,
249
+ transition: ANIMATION_CONFIGS.modal.open,
250
+ }}
251
+ exit={{
252
+ opacity: 0,
253
+ scale: 0.95,
254
+ transition: ANIMATION_CONFIGS.modal.close,
255
+ }}
256
+ >
257
+ <ModalLayout
258
+ title={props.title}
259
+ footer={props.footer}
260
+ className={cn(isFullscreen && 'h-full')}
261
+ footerClassName={
262
+ isFullscreen
263
+ ? 'border-t border-border py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]'
264
+ : undefined
265
+ }
266
+ >
267
+ {props.children}
268
+ </ModalLayout>
269
+ </motion.div>
270
+ </div>
271
+ </DialogPrimitive.Content>
272
+ );
273
+ }
274
+
275
+ // ==========================================================================
276
+ // Bottom Sheet Implementation (Special Case)
277
+ // ==========================================================================
278
+
279
+ function MobileBottomSheet(props: AdaptiveModalProps) {
280
+ const { closeOnOutsideClick = true } = props;
281
+ const sheetRef = useRef<HTMLDivElement>(null);
282
+ const dragControls = useDragControls();
283
+ const [screenHeight, setScreenHeight] = useState(1000);
284
+ const y = useMotionValue(screenHeight);
285
+
286
+ // Sync screen height
287
+ useEffect(() => {
288
+ setScreenHeight(window.innerHeight);
289
+ const onResize = () => setScreenHeight(window.innerHeight);
290
+ window.addEventListener('resize', onResize);
291
+ return () => window.removeEventListener('resize', onResize);
292
+ }, []);
293
+
294
+ // Update initial Y
295
+ useEffect(() => {
296
+ y.set(screenHeight);
297
+ }, [screenHeight, y]);
298
+
299
+ const overlayOpacity = useTransform(y, [0, screenHeight * 0.8], [1, 0], {
300
+ clamp: true,
301
+ });
302
+
303
+ const closeSheet = () => {
304
+ animate(y, [y.get(), screenHeight], ANIMATION_CONFIGS.close).then(props.onClose);
305
+ };
306
+
307
+ const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
308
+ if (info.velocity.y > VELOCITY_THRESHOLD || y.get() > screenHeight * DRAG_THRESHOLD) {
309
+ closeSheet();
310
+ } else {
311
+ animate(y, [y.get(), 0], ANIMATION_CONFIGS.snapBack);
312
+ }
313
+ };
314
+
315
+ return (
316
+ <>
317
+ {/*
318
+ Sheet Overlay - We use motion.div directly here for custom opacity animation,
319
+ but wrapped in DialogPrimitive.Overlay logic implicitly via manual close handling
320
+ */}
321
+ <DialogPrimitive.Overlay asChild>
322
+ <motion.div
323
+ className='fixed inset-0 z-60 bg-overlay backdrop-blur-sm'
324
+ style={{ opacity: overlayOpacity }}
325
+ initial={{ opacity: 0 }}
326
+ animate={{ opacity: 1 }}
327
+ exit={{ opacity: 0 }}
328
+ transition={ANIMATION_CONFIGS.backdrop}
329
+ onClick={closeOnOutsideClick ? closeSheet : undefined}
330
+ />
331
+ </DialogPrimitive.Overlay>
332
+
333
+ <DialogPrimitive.Content
334
+ asChild
335
+ aria-describedby={undefined}
336
+ onInteractOutside={(e) => e.preventDefault()}
337
+ >
338
+ {/*
339
+ We need to prevent Radix from handling pointer-events-none on the container
340
+ because our drag logic needs to work. We manually handle focus trap.
341
+ */}
342
+ <motion.div
343
+ ref={sheetRef}
344
+ className='fixed right-0 bottom-0 left-0 z-70 flex min-h-0 flex-col outline-none'
345
+ style={{ y, maxHeight: '85vh' }}
346
+ initial={{ y: screenHeight }}
347
+ animate={{ y: 0, transition: ANIMATION_CONFIGS.open }}
348
+ exit={{ y: screenHeight, transition: ANIMATION_CONFIGS.close }}
349
+ drag='y'
350
+ dragConstraints={{ top: 0, bottom: screenHeight }}
351
+ dragElastic={{ top: 0.1, bottom: 0 }}
352
+ dragControls={dragControls}
353
+ dragListener={false}
354
+ onDragEnd={handleDragEnd}
355
+ >
356
+ {/* Drag Handle Area */}
357
+ <div
358
+ className='pointer-events-auto absolute right-0 left-0 z-10 flex cursor-grab items-start justify-center active:cursor-grabbing'
359
+ style={{ top: '-20px', height: '20px' }}
360
+ onPointerDown={(e) => dragControls.start(e)}
361
+ >
362
+ {props.showDragIndicator !== false && (
363
+ <div className='mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30' />
364
+ )}
365
+ </div>
366
+
367
+ <ModalLayout
368
+ {...props}
369
+ showCloseButton={false}
370
+ className={cn(
371
+ 'rounded-t-2xl border border-border bg-background shadow-xl',
372
+ props.className,
373
+ )}
374
+ headerClassName='text-center pt-5'
375
+ >
376
+ {props.children}
377
+ </ModalLayout>
378
+ </motion.div>
379
+ </DialogPrimitive.Content>
380
+ </>
381
+ );
382
+ }
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { motion } from 'framer-motion';
4
+ import { useEffect, useRef, useState } from 'react';
5
+
6
+ interface AnimatedCounterProps {
7
+ value: number;
8
+ suffix?: string;
9
+ className?: string;
10
+ itemHeight?: number;
11
+ }
12
+
13
+ /**
14
+ * Анимированный счётчик с эффектом барабана
15
+ */
16
+ export function AnimatedCounter({
17
+ value,
18
+ suffix = '',
19
+ className,
20
+ itemHeight = 48,
21
+ }: AnimatedCounterProps) {
22
+ const prevValueRef = useRef(value);
23
+ const [animationKey, setAnimationKey] = useState(0);
24
+ const [items, setItems] = useState<number[]>([value]);
25
+ const [startY, setStartY] = useState(0);
26
+ const [endY, setEndY] = useState(0);
27
+
28
+ useEffect(() => {
29
+ const prevValue = prevValueRef.current;
30
+ if (prevValue === value) return;
31
+
32
+ // Генерируем диапазон от большего к меньшему (сверху вниз)
33
+ const min = Math.min(prevValue, value);
34
+ const max = Math.max(prevValue, value);
35
+ const range: number[] = [];
36
+ for (let i = max; i >= min; i--) {
37
+ range.push(i);
38
+ }
39
+
40
+ const prevIndex = range.indexOf(prevValue);
41
+ const nextIndex = range.indexOf(value);
42
+
43
+ setItems(range);
44
+ setStartY(-prevIndex * itemHeight);
45
+ setEndY(-nextIndex * itemHeight);
46
+ setAnimationKey((k) => k + 1);
47
+
48
+ prevValueRef.current = value;
49
+ }, [value, itemHeight]);
50
+
51
+ return (
52
+ <div
53
+ className={`relative overflow-hidden ${className ?? ''}`}
54
+ style={{ height: itemHeight }}
55
+ >
56
+ <motion.div
57
+ key={animationKey}
58
+ initial={{ y: startY }}
59
+ animate={{ y: endY }}
60
+ transition={{
61
+ type: 'spring',
62
+ stiffness: 200,
63
+ damping: 20,
64
+ mass: 0.8,
65
+ }}
66
+ >
67
+ {items.map((num) => (
68
+ <div
69
+ key={num}
70
+ className='flex items-center justify-center'
71
+ style={{ height: itemHeight }}
72
+ >
73
+ {num}
74
+ {suffix}
75
+ </div>
76
+ ))}
77
+ </motion.div>
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import * as AvatarPrimitive from '@radix-ui/react-avatar';
4
+ import { type ComponentPropsWithoutRef, type ComponentRef, forwardRef } from 'react';
5
+ import { cn } from '@/lib/utils';
6
+
7
+ const Avatar = forwardRef<
8
+ ComponentRef<typeof AvatarPrimitive.Root>,
9
+ ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
10
+ >(({ className, ...props }, ref) => (
11
+ <AvatarPrimitive.Root
12
+ ref={ref}
13
+ className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
14
+ {...props}
15
+ />
16
+ ));
17
+ Avatar.displayName = AvatarPrimitive.Root.displayName;
18
+
19
+ const AvatarImage = forwardRef<
20
+ ComponentRef<typeof AvatarPrimitive.Image>,
21
+ ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
22
+ >(({ className, ...props }, ref) => (
23
+ <AvatarPrimitive.Image
24
+ ref={ref}
25
+ className={cn('aspect-square h-full w-full', className)}
26
+ {...props}
27
+ />
28
+ ));
29
+ AvatarImage.displayName = AvatarPrimitive.Image.displayName;
30
+
31
+ const AvatarFallback = forwardRef<
32
+ ComponentRef<typeof AvatarPrimitive.Fallback>,
33
+ ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
34
+ >(({ className, ...props }, ref) => (
35
+ <AvatarPrimitive.Fallback
36
+ ref={ref}
37
+ className={cn(
38
+ 'flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium',
39
+ className,
40
+ )}
41
+ {...props}
42
+ />
43
+ ));
44
+ AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
45
+
46
+ export { Avatar, AvatarFallback, AvatarImage };
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Badge - Компонент бейджа/тега
5
+ *
6
+ * Варианты: default, primary, secondary, outline, success, warning, destructive
7
+ * Размеры: sm, md
8
+ * Опционально: иконка слева
9
+ */
10
+
11
+ import { cva, type VariantProps } from 'class-variance-authority';
12
+ import type { HTMLAttributes, ReactNode } from 'react';
13
+ import { cn } from '@/lib/utils';
14
+
15
+ // ==========================================================================
16
+ // Styles with CVA
17
+ // ==========================================================================
18
+
19
+ const badgeVariants = cva(
20
+ 'inline-flex items-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
21
+ {
22
+ variants: {
23
+ variant: {
24
+ default: 'bg-muted text-muted-foreground hover:bg-muted/80',
25
+ primary: 'bg-primary/10 text-primary hover:bg-primary/20',
26
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
27
+ outline: 'border border-border text-muted-foreground',
28
+ success: 'bg-green-500/10 text-green-800 dark:text-green-300',
29
+ warning: 'bg-warning/15 text-warning-foreground',
30
+ destructive: 'bg-destructive/10 text-destructive',
31
+ },
32
+ size: {
33
+ sm: 'px-2 py-0.5 text-xs gap-1',
34
+ md: 'px-3 py-1 text-sm gap-1.5',
35
+ },
36
+ rounded: {
37
+ full: 'rounded-full',
38
+ lg: 'rounded-lg',
39
+ md: 'rounded-md',
40
+ sm: 'rounded-sm',
41
+ },
42
+ },
43
+ defaultVariants: {
44
+ variant: 'default',
45
+ size: 'md',
46
+ rounded: 'full',
47
+ },
48
+ },
49
+ );
50
+
51
+ const iconSizes: Record<NonNullable<VariantProps<typeof badgeVariants>['size']>, string> = {
52
+ sm: 'w-3 h-3',
53
+ md: 'w-4 h-4',
54
+ };
55
+
56
+ // ==========================================================================
57
+ // Types
58
+ // ==========================================================================
59
+
60
+ export interface BadgeProps
61
+ extends HTMLAttributes<HTMLSpanElement>,
62
+ VariantProps<typeof badgeVariants> {
63
+ icon?: ReactNode;
64
+ }
65
+
66
+ // ==========================================================================
67
+ // Component
68
+ // ==========================================================================
69
+
70
+ export function Badge({
71
+ children,
72
+ variant,
73
+ size = 'md', // Default value needs to be explicit for iconSizes lookup
74
+ rounded,
75
+ icon,
76
+ className,
77
+ ...props
78
+ }: BadgeProps) {
79
+ return (
80
+ <span className={cn(badgeVariants({ variant, size, rounded }), className)} {...props}>
81
+ {icon && (
82
+ <span
83
+ className={cn(
84
+ iconSizes[size || 'md'],
85
+ 'shrink-0 [&>svg]:w-full [&>svg]:h-full [&>img]:w-full [&>img]:h-full',
86
+ )}
87
+ >
88
+ {icon}
89
+ </span>
90
+ )}
91
+ {children}
92
+ </span>
93
+ );
94
+ }
@@ -0,0 +1,24 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const buttonVariants = cva(
4
+ 'inline-flex items-center justify-center font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
5
+ {
6
+ variants: {
7
+ variant: {
8
+ default: 'bg-muted text-foreground hover:bg-muted/80',
9
+ primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
10
+ ghost: 'hover:bg-muted text-muted-foreground hover:text-foreground',
11
+ outline: 'border border-border bg-transparent hover:bg-muted',
12
+ destructive: 'bg-destructive text-white hover:bg-destructive/90',
13
+ },
14
+ size: {
15
+ sm: 'h-8 px-3 text-sm rounded-lg gap-1.5',
16
+ md: 'h-10 px-4 text-sm rounded-xl gap-2',
17
+ lg: 'h-12 px-6 text-base rounded-xl gap-2',
18
+ icon: 'h-8 w-8 rounded-lg',
19
+ 'icon-md': 'h-10 w-10 rounded-xl',
20
+ },
21
+ },
22
+ defaultVariants: { variant: 'default', size: 'md' },
23
+ },
24
+ );