feat: redesign auth layout, dashboard sidebar, landing page social proof & new static pages
Split-panel auth layout with dark promo panel, sectioned dashboard nav with theme toggle, testimonials/social proof on landing page, e-commerce Remotion demo, and static pages (about, blog, contact, terms, privacy, KVKK). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
57
.agents/skills/remotion-best-practices/SKILL.md
Normal file
57
.agents/skills/remotion-best-practices/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: remotion-best-practices
|
||||
description: Best practices for Remotion - Video creation in React
|
||||
metadata:
|
||||
tags: remotion, video, react, animation, composition
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skills whenever you are dealing with Remotion code to obtain the domain-specific knowledge.
|
||||
|
||||
## Captions
|
||||
|
||||
When dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.
|
||||
|
||||
## Using FFmpeg
|
||||
|
||||
For some video operations, such as trimming videos or detecting silence, FFmpeg should be used. Load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.
|
||||
|
||||
## Audio visualization
|
||||
|
||||
When needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.
|
||||
|
||||
## How to use
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
- [rules/3d.md](rules/3d.md) - 3D content in Remotion using Three.js and React Three Fiber
|
||||
- [rules/animations.md](rules/animations.md) - Fundamental animation skills for Remotion
|
||||
- [rules/assets.md](rules/assets.md) - Importing images, videos, audio, and fonts into Remotion
|
||||
- [rules/audio.md](rules/audio.md) - Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||
- [rules/calculate-metadata.md](rules/calculate-metadata.md) - Dynamically set composition duration, dimensions, and props
|
||||
- [rules/can-decode.md](rules/can-decode.md) - Check if a video can be decoded by the browser using Mediabunny
|
||||
- [rules/charts.md](rules/charts.md) - Chart and data visualization patterns for Remotion (bar, pie, line, stock charts)
|
||||
- [rules/compositions.md](rules/compositions.md) - Defining compositions, stills, folders, default props and dynamic metadata
|
||||
- [rules/extract-frames.md](rules/extract-frames.md) - Extract frames from videos at specific timestamps using Mediabunny
|
||||
- [rules/fonts.md](rules/fonts.md) - Loading Google Fonts and local fonts in Remotion
|
||||
- [rules/get-audio-duration.md](rules/get-audio-duration.md) - Getting the duration of an audio file in seconds with Mediabunny
|
||||
- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - Getting the width and height of a video file with Mediabunny
|
||||
- [rules/get-video-duration.md](rules/get-video-duration.md) - Getting the duration of a video file in seconds with Mediabunny
|
||||
- [rules/gifs.md](rules/gifs.md) - Displaying GIFs synchronized with Remotion's timeline
|
||||
- [rules/images.md](rules/images.md) - Embedding images in Remotion using the Img component
|
||||
- [rules/light-leaks.md](rules/light-leaks.md) - Light leak overlay effects using @remotion/light-leaks
|
||||
- [rules/lottie.md](rules/lottie.md) - Embedding Lottie animations in Remotion
|
||||
- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - Measuring DOM element dimensions in Remotion
|
||||
- [rules/measuring-text.md](rules/measuring-text.md) - Measuring text dimensions, fitting text to containers, and checking overflow
|
||||
- [rules/sequencing.md](rules/sequencing.md) - Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||
- [rules/tailwind.md](rules/tailwind.md) - Using TailwindCSS in Remotion
|
||||
- [rules/text-animations.md](rules/text-animations.md) - Typography and text animation patterns for Remotion
|
||||
- [rules/timing.md](rules/timing.md) - Interpolation curves in Remotion - linear, easing, spring animations
|
||||
- [rules/transitions.md](rules/transitions.md) - Scene transition patterns for Remotion
|
||||
- [rules/transparent-videos.md](rules/transparent-videos.md) - Rendering out a video with transparency
|
||||
- [rules/trimming.md](rules/trimming.md) - Trimming patterns for Remotion - cut the beginning or end of animations
|
||||
- [rules/videos.md](rules/videos.md) - Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||
- [rules/parameters.md](rules/parameters.md) - Make a video parametrizable by adding a Zod schema
|
||||
- [rules/maps.md](rules/maps.md) - Add a map using Mapbox and animate it
|
||||
- [rules/voiceover.md](rules/voiceover.md) - Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS
|
||||
86
.agents/skills/remotion-best-practices/rules/3d.md
Normal file
86
.agents/skills/remotion-best-practices/rules/3d.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: 3d
|
||||
description: 3D content in Remotion using Three.js and React Three Fiber.
|
||||
metadata:
|
||||
tags: 3d, three, threejs
|
||||
---
|
||||
|
||||
# Using Three.js and React Three Fiber in Remotion
|
||||
|
||||
Follow React Three Fiber and Three.js best practices.
|
||||
Only the following Remotion-specific rules need to be followed:
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the `@remotion/three` package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/three # If project uses npm
|
||||
bunx remotion add @remotion/three # If project uses bun
|
||||
yarn remotion add @remotion/three # If project uses yarn
|
||||
pnpm exec remotion add @remotion/three # If project uses pnpm
|
||||
```
|
||||
|
||||
## Using ThreeCanvas
|
||||
|
||||
You MUST wrap 3D content in `<ThreeCanvas>` and include proper lighting.
|
||||
`<ThreeCanvas>` MUST have a `width` and `height` prop.
|
||||
|
||||
```tsx
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
import { useVideoConfig } from "remotion";
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight position={[5, 5, 5]} intensity={0.8} />
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 32]} />
|
||||
<meshStandardMaterial color="red" />
|
||||
</mesh>
|
||||
</ThreeCanvas>
|
||||
```
|
||||
|
||||
## No animations not driven by `useCurrentFrame()`
|
||||
|
||||
Shaders, models etc MUST NOT animate by themselves.
|
||||
No animations are allowed unless they are driven by `useCurrentFrame()`.
|
||||
Otherwise, it will cause flickering during rendering.
|
||||
|
||||
Using `useFrame()` from `@react-three/fiber` is forbidden.
|
||||
|
||||
## Animate using `useCurrentFrame()`
|
||||
|
||||
Use `useCurrentFrame()` to perform animations.
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const rotationY = frame * 0.02;
|
||||
|
||||
<mesh rotation={[0, rotationY, 0]}>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#4a9eff" />
|
||||
</mesh>
|
||||
```
|
||||
|
||||
## Using `<Sequence>` inside `<ThreeCanvas>`
|
||||
|
||||
The `layout` prop of any `<Sequence>` inside a `<ThreeCanvas>` must be set to `none`.
|
||||
|
||||
```tsx
|
||||
import { Sequence } from "remotion";
|
||||
import { ThreeCanvas } from "@remotion/three";
|
||||
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
<ThreeCanvas width={width} height={height}>
|
||||
<Sequence layout="none">
|
||||
<mesh>
|
||||
<boxGeometry args={[2, 2, 2]} />
|
||||
<meshStandardMaterial color="#4a9eff" />
|
||||
</mesh>
|
||||
</Sequence>
|
||||
</ThreeCanvas>
|
||||
```
|
||||
29
.agents/skills/remotion-best-practices/rules/animations.md
Normal file
29
.agents/skills/remotion-best-practices/rules/animations.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: animations
|
||||
description: Fundamental animation skills for Remotion
|
||||
metadata:
|
||||
tags: animations, transitions, frames, useCurrentFrame
|
||||
---
|
||||
|
||||
All animations MUST be driven by the `useCurrentFrame()` hook.
|
||||
Write animations in seconds and multiply them by the `fps` value from `useVideoConfig()`.
|
||||
|
||||
```tsx
|
||||
import { useCurrentFrame } from "remotion";
|
||||
|
||||
export const FadeIn = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const opacity = interpolate(frame, [0, 2 * fps], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ opacity }}>Hello World!</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
CSS transitions or animations are FORBIDDEN - they will not render correctly.
|
||||
Tailwind animation class names are FORBIDDEN - they will not render correctly.
|
||||
78
.agents/skills/remotion-best-practices/rules/assets.md
Normal file
78
.agents/skills/remotion-best-practices/rules/assets.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: assets
|
||||
description: Importing images, videos, audio, and fonts into Remotion
|
||||
metadata:
|
||||
tags: assets, staticFile, images, fonts, public
|
||||
---
|
||||
|
||||
# Importing assets in Remotion
|
||||
|
||||
## The public folder
|
||||
|
||||
Place assets in the `public/` folder at your project root.
|
||||
|
||||
## Using staticFile()
|
||||
|
||||
You MUST use `staticFile()` to reference files from the `public/` folder:
|
||||
|
||||
```tsx
|
||||
import {Img, staticFile} from 'remotion';
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Img src={staticFile('logo.png')} />;
|
||||
};
|
||||
```
|
||||
|
||||
The function returns an encoded URL that works correctly when deploying to subdirectories.
|
||||
|
||||
## Using with components
|
||||
|
||||
**Images:**
|
||||
|
||||
```tsx
|
||||
import {Img, staticFile} from 'remotion';
|
||||
|
||||
<Img src={staticFile('photo.png')} />;
|
||||
```
|
||||
|
||||
**Videos:**
|
||||
|
||||
```tsx
|
||||
import {Video} from '@remotion/media';
|
||||
import {staticFile} from 'remotion';
|
||||
|
||||
<Video src={staticFile('clip.mp4')} />;
|
||||
```
|
||||
|
||||
**Audio:**
|
||||
|
||||
```tsx
|
||||
import {Audio} from '@remotion/media';
|
||||
import {staticFile} from 'remotion';
|
||||
|
||||
<Audio src={staticFile('music.mp3')} />;
|
||||
```
|
||||
|
||||
**Fonts:**
|
||||
|
||||
```tsx
|
||||
import {staticFile} from 'remotion';
|
||||
|
||||
const fontFamily = new FontFace('MyFont', `url(${staticFile('font.woff2')})`);
|
||||
await fontFamily.load();
|
||||
document.fonts.add(fontFamily);
|
||||
```
|
||||
|
||||
## Remote URLs
|
||||
|
||||
Remote URLs can be used directly without `staticFile()`:
|
||||
|
||||
```tsx
|
||||
<Img src="https://example.com/image.png" />
|
||||
<Video src="https://remotion.media/video.mp4" />
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
- Remotion components (`<Img>`, `<Video>`, `<Audio>`) ensure assets are fully loaded before rendering
|
||||
- Special characters in filenames (`#`, `?`, `&`) are automatically encoded
|
||||
@@ -0,0 +1,173 @@
|
||||
import {loadFont} from '@remotion/google-fonts/Inter';
|
||||
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
const {fontFamily} = loadFont();
|
||||
|
||||
const COLOR_BAR = '#D4AF37';
|
||||
const COLOR_TEXT = '#ffffff';
|
||||
const COLOR_MUTED = '#888888';
|
||||
const COLOR_BG = '#0a0a0a';
|
||||
const COLOR_AXIS = '#333333';
|
||||
|
||||
// Ideal composition size: 1280x720
|
||||
|
||||
const Title: React.FC<{children: React.ReactNode}> = ({children}) => (
|
||||
<div style={{textAlign: 'center', marginBottom: 40}}>
|
||||
<div style={{color: COLOR_TEXT, fontSize: 48, fontWeight: 600}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const YAxis: React.FC<{steps: number[]; height: number}> = ({
|
||||
steps,
|
||||
height,
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
height,
|
||||
paddingRight: 16,
|
||||
}}
|
||||
>
|
||||
{steps
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
color: COLOR_MUTED,
|
||||
fontSize: 20,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{step.toLocaleString()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Bar: React.FC<{
|
||||
height: number;
|
||||
progress: number;
|
||||
}> = ({height, progress}) => (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
backgroundColor: COLOR_BAR,
|
||||
borderRadius: '8px 8px 0 0',
|
||||
opacity: progress,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const XAxis: React.FC<{
|
||||
children: React.ReactNode;
|
||||
labels: string[];
|
||||
height: number;
|
||||
}> = ({children, labels, height}) => (
|
||||
<div style={{flex: 1, display: 'flex', flexDirection: 'column'}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 16,
|
||||
height,
|
||||
borderLeft: `2px solid ${COLOR_AXIS}`,
|
||||
borderBottom: `2px solid ${COLOR_AXIS}`,
|
||||
paddingLeft: 16,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
paddingLeft: 16,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
color: COLOR_MUTED,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps, height} = useVideoConfig();
|
||||
|
||||
const data = [
|
||||
{month: 'Jan', price: 2039},
|
||||
{month: 'Mar', price: 2160},
|
||||
{month: 'May', price: 2327},
|
||||
{month: 'Jul', price: 2426},
|
||||
{month: 'Sep', price: 2634},
|
||||
{month: 'Nov', price: 2672},
|
||||
];
|
||||
|
||||
const minPrice = 2000;
|
||||
const maxPrice = 2800;
|
||||
const priceRange = maxPrice - minPrice;
|
||||
const chartHeight = height - 280;
|
||||
const yAxisSteps = [2000, 2400, 2800];
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: COLOR_BG,
|
||||
padding: 60,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
<Title>Gold Price 2024</Title>
|
||||
|
||||
<div style={{display: 'flex', flex: 1}}>
|
||||
<YAxis steps={yAxisSteps} height={chartHeight} />
|
||||
<XAxis height={chartHeight} labels={data.map((d) => d.month)}>
|
||||
{data.map((item, i) => {
|
||||
const progress = spring({
|
||||
frame: frame - i * 5 - 10,
|
||||
fps,
|
||||
config: {damping: 18, stiffness: 80},
|
||||
});
|
||||
|
||||
const barHeight =
|
||||
((item.price - minPrice) / priceRange) * chartHeight * progress;
|
||||
|
||||
return (
|
||||
<Bar key={item.month} height={barHeight} progress={progress} />
|
||||
);
|
||||
})}
|
||||
</XAxis>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
|
||||
const COLOR_BG = '#ffffff';
|
||||
const COLOR_TEXT = '#000000';
|
||||
const FULL_TEXT = 'From prompt to motion graphics. This is Remotion.';
|
||||
const PAUSE_AFTER = 'From prompt to motion graphics.';
|
||||
const FONT_SIZE = 72;
|
||||
const FONT_WEIGHT = 700;
|
||||
const CHAR_FRAMES = 2;
|
||||
const CURSOR_BLINK_FRAMES = 16;
|
||||
const PAUSE_SECONDS = 1;
|
||||
|
||||
// Ideal composition size: 1280x720
|
||||
|
||||
const getTypedText = ({
|
||||
frame,
|
||||
fullText,
|
||||
pauseAfter,
|
||||
charFrames,
|
||||
pauseFrames,
|
||||
}: {
|
||||
frame: number;
|
||||
fullText: string;
|
||||
pauseAfter: string;
|
||||
charFrames: number;
|
||||
pauseFrames: number;
|
||||
}): string => {
|
||||
const pauseIndex = fullText.indexOf(pauseAfter);
|
||||
const preLen =
|
||||
pauseIndex >= 0 ? pauseIndex + pauseAfter.length : fullText.length;
|
||||
|
||||
let typedChars = 0;
|
||||
if (frame < preLen * charFrames) {
|
||||
typedChars = Math.floor(frame / charFrames);
|
||||
} else if (frame < preLen * charFrames + pauseFrames) {
|
||||
typedChars = preLen;
|
||||
} else {
|
||||
const postPhase = frame - preLen * charFrames - pauseFrames;
|
||||
typedChars = Math.min(
|
||||
fullText.length,
|
||||
preLen + Math.floor(postPhase / charFrames),
|
||||
);
|
||||
}
|
||||
return fullText.slice(0, typedChars);
|
||||
};
|
||||
|
||||
const Cursor: React.FC<{
|
||||
frame: number;
|
||||
blinkFrames: number;
|
||||
symbol?: string;
|
||||
}> = ({frame, blinkFrames, symbol = '\u258C'}) => {
|
||||
const opacity = interpolate(
|
||||
frame % blinkFrames,
|
||||
[0, blinkFrames / 2, blinkFrames],
|
||||
[1, 0, 1],
|
||||
{extrapolateLeft: 'clamp', extrapolateRight: 'clamp'},
|
||||
);
|
||||
|
||||
return <span style={{opacity}}>{symbol}</span>;
|
||||
};
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
const pauseFrames = Math.round(fps * PAUSE_SECONDS);
|
||||
|
||||
const typedText = getTypedText({
|
||||
frame,
|
||||
fullText: FULL_TEXT,
|
||||
pauseAfter: PAUSE_AFTER,
|
||||
charFrames: CHAR_FRAMES,
|
||||
pauseFrames,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: COLOR_BG,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: COLOR_TEXT,
|
||||
fontSize: FONT_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<span>{typedText}</span>
|
||||
<Cursor frame={frame} blinkFrames={CURSOR_BLINK_FRAMES} />
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import {loadFont} from '@remotion/google-fonts/Inter';
|
||||
import React from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
|
||||
/*
|
||||
* Highlight a word in a sentence with a spring-animated wipe effect.
|
||||
*/
|
||||
|
||||
// Ideal composition size: 1280x720
|
||||
|
||||
const COLOR_BG = '#ffffff';
|
||||
const COLOR_TEXT = '#000000';
|
||||
const COLOR_HIGHLIGHT = '#A7C7E7';
|
||||
const FULL_TEXT = 'This is Remotion.';
|
||||
const HIGHLIGHT_WORD = 'Remotion';
|
||||
const FONT_SIZE = 72;
|
||||
const FONT_WEIGHT = 700;
|
||||
const HIGHLIGHT_START_FRAME = 30;
|
||||
const HIGHLIGHT_WIPE_DURATION = 18;
|
||||
|
||||
const {fontFamily} = loadFont();
|
||||
|
||||
const Highlight: React.FC<{
|
||||
word: string;
|
||||
color: string;
|
||||
delay: number;
|
||||
durationInFrames: number;
|
||||
}> = ({word, color, delay, durationInFrames}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
const highlightProgress = spring({
|
||||
fps,
|
||||
frame,
|
||||
config: {damping: 200},
|
||||
delay,
|
||||
durationInFrames,
|
||||
});
|
||||
const scaleX = Math.max(0, Math.min(1, highlightProgress));
|
||||
|
||||
return (
|
||||
<span style={{position: 'relative', display: 'inline-block'}}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '50%',
|
||||
height: '1.05em',
|
||||
transform: `translateY(-50%) scaleX(${scaleX})`,
|
||||
transformOrigin: 'left center',
|
||||
backgroundColor: color,
|
||||
borderRadius: '0.18em',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{position: 'relative', zIndex: 1}}>{word}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const highlightIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
|
||||
const hasHighlight = highlightIndex >= 0;
|
||||
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightIndex) : FULL_TEXT;
|
||||
const postText = hasHighlight
|
||||
? FULL_TEXT.slice(highlightIndex + HIGHLIGHT_WORD.length)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: COLOR_BG,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: COLOR_TEXT,
|
||||
fontSize: FONT_SIZE,
|
||||
fontWeight: FONT_WEIGHT,
|
||||
}}
|
||||
>
|
||||
{hasHighlight ? (
|
||||
<>
|
||||
<span>{preText}</span>
|
||||
<Highlight
|
||||
word={HIGHLIGHT_WORD}
|
||||
color={COLOR_HIGHLIGHT}
|
||||
delay={HIGHLIGHT_START_FRAME}
|
||||
durationInFrames={HIGHLIGHT_WIPE_DURATION}
|
||||
/>
|
||||
<span>{postText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{FULL_TEXT}</span>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: audio-visualization
|
||||
description: Audio visualization patterns - spectrum bars, waveforms, bass-reactive effects
|
||||
metadata:
|
||||
tags: audio, visualization, spectrum, waveform, bass, music, audiogram, frequency
|
||||
---
|
||||
|
||||
# Audio Visualization in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media-utils
|
||||
```
|
||||
|
||||
## Loading Audio Data
|
||||
|
||||
Use `useWindowedAudioData()` (https://www.remotion.dev/docs/use-windowed-audio-data) to load audio data:
|
||||
|
||||
```tsx
|
||||
import { useWindowedAudioData } from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("podcast.wav"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
```
|
||||
|
||||
## Spectrum Bar Visualization
|
||||
|
||||
Use `visualizeAudio()` (https://www.remotion.dev/docs/visualize-audio) to get frequency data for bar charts:
|
||||
|
||||
```tsx
|
||||
import { useWindowedAudioData, visualizeAudio } from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("music.mp3"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
|
||||
if (!audioData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frequencies = visualizeAudio({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 256,
|
||||
optimizeFor: "speed",
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "flex-end", height: 200 }}>
|
||||
{frequencies.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: `${v * 100}%`,
|
||||
backgroundColor: "#0b84f3",
|
||||
margin: "0 1px",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
- `numberOfSamples` must be power of 2 (32, 64, 128, 256, 512, 1024)
|
||||
- Values range 0-1; left of array = bass, right = highs
|
||||
- Use `optimizeFor: "speed"` for Lambda or high sample counts
|
||||
|
||||
**Important:** When passing `audioData` to child components, also pass the `frame` from the parent. Do not call `useCurrentFrame()` in each child - this causes discontinuous visualization when children are inside `<Sequence>` with offsets.
|
||||
|
||||
## Waveform Visualization
|
||||
|
||||
Use `visualizeAudioWaveform()` (https://www.remotion.dev/docs/media-utils/visualize-audio-waveform) with `createSmoothSvgPath()` (https://www.remotion.dev/docs/media-utils/create-smooth-svg-path) for oscilloscope-style displays:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
createSmoothSvgPath,
|
||||
useWindowedAudioData,
|
||||
visualizeAudioWaveform,
|
||||
} from "@remotion/media-utils";
|
||||
import { staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { width, fps } = useVideoConfig();
|
||||
const HEIGHT = 200;
|
||||
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile("voice.wav"),
|
||||
frame,
|
||||
fps,
|
||||
windowInSeconds: 30,
|
||||
});
|
||||
|
||||
if (!audioData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const waveform = visualizeAudioWaveform({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 256,
|
||||
windowInSeconds: 0.5,
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
const path = createSmoothSvgPath({
|
||||
points: waveform.map((y, i) => ({
|
||||
x: (i / (waveform.length - 1)) * width,
|
||||
y: HEIGHT / 2 + (y * HEIGHT) / 2,
|
||||
})),
|
||||
});
|
||||
|
||||
return (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
<path d={path} fill="none" stroke="#0b84f3" strokeWidth={2} />
|
||||
</svg>
|
||||
);
|
||||
```
|
||||
|
||||
## Bass-Reactive Effects
|
||||
|
||||
Extract low frequencies for beat-reactive animations:
|
||||
|
||||
```tsx
|
||||
const frequencies = visualizeAudio({
|
||||
fps,
|
||||
frame,
|
||||
audioData,
|
||||
numberOfSamples: 128,
|
||||
optimizeFor: "speed",
|
||||
dataOffsetInSeconds,
|
||||
});
|
||||
|
||||
const lowFrequencies = frequencies.slice(0, 32);
|
||||
const bassIntensity =
|
||||
lowFrequencies.reduce((sum, v) => sum + v, 0) / lowFrequencies.length;
|
||||
|
||||
const scale = 1 + bassIntensity * 0.5;
|
||||
const opacity = Math.min(0.6, bassIntensity * 0.8);
|
||||
```
|
||||
|
||||
## Volume-Based Waveform
|
||||
|
||||
Use `getWaveformPortion()` (https://www.remotion.dev/docs/get-waveform-portion) when you need simplified volume data instead of frequency spectrum:
|
||||
|
||||
```tsx
|
||||
import { getWaveformPortion } from "@remotion/media-utils";
|
||||
import { useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const currentTimeInSeconds = frame / fps;
|
||||
|
||||
const waveform = getWaveformPortion({
|
||||
audioData,
|
||||
startTimeInSeconds: currentTimeInSeconds,
|
||||
durationInSeconds: 5,
|
||||
numberOfSamples: 50,
|
||||
});
|
||||
|
||||
// Returns array of { index, amplitude } objects (amplitude: 0-1)
|
||||
waveform.map((bar) => (
|
||||
<div key={bar.index} style={{ height: bar.amplitude * 100 }} />
|
||||
));
|
||||
```
|
||||
|
||||
## Postprocessing
|
||||
|
||||
Low frequencies naturally dominate. Apply logarithmic scaling for visual balance:
|
||||
|
||||
```tsx
|
||||
const minDb = -100;
|
||||
const maxDb = -30;
|
||||
|
||||
const scaled = frequencies.map((value) => {
|
||||
const db = 20 * Math.log10(value);
|
||||
return (db - minDb) / (maxDb - minDb);
|
||||
});
|
||||
```
|
||||
169
.agents/skills/remotion-best-practices/rules/audio.md
Normal file
169
.agents/skills/remotion-best-practices/rules/audio.md
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: audio
|
||||
description: Using audio and sound in Remotion - importing, trimming, volume, speed, pitch
|
||||
metadata:
|
||||
tags: audio, media, trim, volume, speed, loop, pitch, mute, sound, sfx
|
||||
---
|
||||
|
||||
# Using audio in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/media package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media
|
||||
```
|
||||
|
||||
## Importing Audio
|
||||
|
||||
Use `<Audio>` from `@remotion/media` to add audio to your composition.
|
||||
|
||||
```tsx
|
||||
import { Audio } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Audio src={staticFile("audio.mp3")} />;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported:
|
||||
|
||||
```tsx
|
||||
<Audio src="https://remotion.media/audio.mp3" />
|
||||
```
|
||||
|
||||
By default, audio plays from the start, at full volume and full length.
|
||||
Multiple audio tracks can be layered by adding multiple `<Audio>` components.
|
||||
|
||||
## Trimming
|
||||
|
||||
Use `trimBefore` and `trimAfter` to remove portions of the audio. Values are in frames.
|
||||
|
||||
```tsx
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||
trimAfter={10 * fps} // End at the 10 second mark
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
The audio still starts playing at the beginning of the composition - only the specified portion is played.
|
||||
|
||||
## Delaying
|
||||
|
||||
Wrap the audio in a `<Sequence>` to delay when it starts:
|
||||
|
||||
```tsx
|
||||
import { Sequence, staticFile } from "remotion";
|
||||
import { Audio } from "@remotion/media";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Sequence from={1 * fps}>
|
||||
<Audio src={staticFile("audio.mp3")} />
|
||||
</Sequence>
|
||||
);
|
||||
```
|
||||
|
||||
The audio will start playing after 1 second.
|
||||
|
||||
## Volume
|
||||
|
||||
Set a static volume (0 to 1):
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} volume={0.5} />
|
||||
```
|
||||
|
||||
Or use a callback for dynamic volume based on the current frame:
|
||||
|
||||
```tsx
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
The value of `f` starts at 0 when the audio begins to play, not the composition frame.
|
||||
|
||||
## Muting
|
||||
|
||||
Use `muted` to silence the audio. It can be set dynamically:
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
muted={frame >= 2 * fps && frame <= 4 * fps} // Mute between 2s and 4s
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Speed
|
||||
|
||||
Use `playbackRate` to change the playback speed:
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} playbackRate={2} /> {/* 2x speed */}
|
||||
<Audio src={staticFile("audio.mp3")} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
Reverse playback is not supported.
|
||||
|
||||
## Looping
|
||||
|
||||
Use `loop` to loop the audio indefinitely:
|
||||
|
||||
```tsx
|
||||
<Audio src={staticFile("audio.mp3")} loop />
|
||||
```
|
||||
|
||||
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||
|
||||
- `"repeat"`: Frame count resets to 0 each loop (default)
|
||||
- `"extend"`: Frame count continues incrementing
|
||||
|
||||
```tsx
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
loop
|
||||
loopVolumeCurveBehavior="extend"
|
||||
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||
/>
|
||||
```
|
||||
|
||||
## Pitch
|
||||
|
||||
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||
|
||||
```tsx
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
toneFrequency={1.5} // Higher pitch
|
||||
/>
|
||||
<Audio
|
||||
src={staticFile("audio.mp3")}
|
||||
toneFrequency={0.8} // Lower pitch
|
||||
/>
|
||||
```
|
||||
|
||||
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
name: calculate-metadata
|
||||
description: Dynamically set composition duration, dimensions, and props
|
||||
metadata:
|
||||
tags: calculateMetadata, duration, dimensions, props, dynamic
|
||||
---
|
||||
|
||||
# Using calculateMetadata
|
||||
|
||||
Use `calculateMetadata` on a `<Composition>` to dynamically set duration, dimensions, and transform props before rendering.
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="MyComp"
|
||||
component={MyComponent}
|
||||
durationInFrames={300}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ videoSrc: "https://remotion.media/video.mp4" }}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>
|
||||
```
|
||||
|
||||
## Setting duration based on a video
|
||||
|
||||
Use the [`getVideoDuration`](./get-video-duration.md) and [`getVideoDimensions`](./get-video-dimensions.md) skills to get the video duration and dimensions:
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
import { getVideoDuration } from "./get-video-duration";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const durationInSeconds = await getVideoDuration(props.videoSrc);
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(durationInSeconds * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Matching dimensions of a video
|
||||
|
||||
Use the [`getVideoDimensions`](./get-video-dimensions.md) skill to get the video dimensions:
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
import { getVideoDuration } from "./get-video-duration";
|
||||
import { getVideoDimensions } from "./get-video-dimensions";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const dimensions = await getVideoDimensions(props.videoSrc);
|
||||
|
||||
return {
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Setting duration based on multiple videos
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const metadataPromises = props.videos.map((video) =>
|
||||
getVideoDuration(video.src),
|
||||
);
|
||||
const allMetadata = await Promise.all(metadataPromises);
|
||||
|
||||
const totalDuration = allMetadata.reduce(
|
||||
(sum, durationInSeconds) => sum + durationInSeconds,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(totalDuration * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Setting a default outName
|
||||
|
||||
Set the default output filename based on props:
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultOutName: `video-${props.id}.mp4`,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Transforming props
|
||||
|
||||
Fetch data or transform props before rendering:
|
||||
|
||||
```tsx
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
abortSignal,
|
||||
}) => {
|
||||
const response = await fetch(props.dataUrl, { signal: abortSignal });
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
props: {
|
||||
...props,
|
||||
fetchedData: data,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The `abortSignal` cancels stale requests when props change in the Studio.
|
||||
|
||||
## Return value
|
||||
|
||||
All fields are optional. Returned values override the `<Composition>` props:
|
||||
|
||||
- `durationInFrames`: Number of frames
|
||||
- `width`: Composition width in pixels
|
||||
- `height`: Composition height in pixels
|
||||
- `fps`: Frames per second
|
||||
- `props`: Transformed props passed to the component
|
||||
- `defaultOutName`: Default output filename
|
||||
- `defaultCodec`: Default codec for rendering
|
||||
75
.agents/skills/remotion-best-practices/rules/can-decode.md
Normal file
75
.agents/skills/remotion-best-practices/rules/can-decode.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: can-decode
|
||||
description: Check if a video can be decoded by the browser using Mediabunny
|
||||
metadata:
|
||||
tags: decode, validation, video, audio, compatibility, browser
|
||||
---
|
||||
|
||||
# Checking if a video can be decoded
|
||||
|
||||
Use Mediabunny to check if a video can be decoded by the browser before attempting to play it.
|
||||
|
||||
## The `canDecode()` function
|
||||
|
||||
This function can be copy-pasted into any project.
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const canDecode = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
await input.getFormat();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (videoTrack && !(await videoTrack.canDecode())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
if (audioTrack && !(await audioTrack.canDecode())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const src = "https://remotion.media/video.mp4";
|
||||
const isDecodable = await canDecode(src);
|
||||
|
||||
if (isDecodable) {
|
||||
console.log("Video can be decoded");
|
||||
} else {
|
||||
console.log("Video cannot be decoded by this browser");
|
||||
}
|
||||
```
|
||||
|
||||
## Using with Blob
|
||||
|
||||
For file uploads or drag-and-drop, use `BlobSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, BlobSource } from "mediabunny";
|
||||
|
||||
export const canDecodeBlob = async (blob: Blob) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new BlobSource(blob),
|
||||
});
|
||||
|
||||
// Same validation logic as above
|
||||
};
|
||||
```
|
||||
120
.agents/skills/remotion-best-practices/rules/charts.md
Normal file
120
.agents/skills/remotion-best-practices/rules/charts.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
name: charts
|
||||
description: Chart and data visualization patterns for Remotion. Use when creating bar charts, pie charts, line charts, stock graphs, or any data-driven animations.
|
||||
metadata:
|
||||
tags: charts, data, visualization, bar-chart, pie-chart, line-chart, stock-chart, svg-paths, graphs
|
||||
---
|
||||
|
||||
# Charts in Remotion
|
||||
|
||||
Create charts using React code - HTML, SVG, and D3.js are all supported.
|
||||
|
||||
Disable all animations from third party libraries - they cause flickering.
|
||||
Drive all animations from `useCurrentFrame()`.
|
||||
|
||||
## Bar Chart
|
||||
|
||||
```tsx
|
||||
const STAGGER_DELAY = 5;
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const bars = data.map((item, i) => {
|
||||
const height = spring({
|
||||
frame,
|
||||
fps,
|
||||
delay: i * STAGGER_DELAY,
|
||||
config: { damping: 200 },
|
||||
});
|
||||
return <div style={{ height: height * item.value }} />;
|
||||
});
|
||||
```
|
||||
|
||||
## Pie Chart
|
||||
|
||||
Animate segments using stroke-dashoffset, starting from 12 o'clock:
|
||||
|
||||
```tsx
|
||||
const progress = interpolate(frame, [0, 100], [0, 1]);
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const segmentLength = (value / total) * circumference;
|
||||
const offset = interpolate(progress, [0, 1], [segmentLength, 0]);
|
||||
|
||||
<circle
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${segmentLength} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Line Chart / Path Animation
|
||||
|
||||
Use `@remotion/paths` for animating SVG paths (line charts, stock graphs, signatures).
|
||||
|
||||
Install: `npx remotion add @remotion/paths`
|
||||
Docs: https://remotion.dev/docs/paths.md
|
||||
|
||||
### Convert data points to SVG path
|
||||
|
||||
```tsx
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
const generateLinePath = (points: Point[]): string => {
|
||||
if (points.length < 2) return "";
|
||||
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
|
||||
};
|
||||
```
|
||||
|
||||
### Draw path with animation
|
||||
|
||||
```tsx
|
||||
import { evolvePath } from "@remotion/paths";
|
||||
|
||||
const path = "M 100 200 L 200 150 L 300 180 L 400 100";
|
||||
const progress = interpolate(frame, [0, 2 * fps], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.quad),
|
||||
});
|
||||
|
||||
const { strokeDasharray, strokeDashoffset } = evolvePath(progress, path);
|
||||
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#FF3232"
|
||||
strokeWidth={4}
|
||||
strokeDasharray={strokeDasharray}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Follow path with marker/arrow
|
||||
|
||||
```tsx
|
||||
import {
|
||||
getLength,
|
||||
getPointAtLength,
|
||||
getTangentAtLength,
|
||||
} from "@remotion/paths";
|
||||
|
||||
const pathLength = getLength(path);
|
||||
const point = getPointAtLength(path, progress * pathLength);
|
||||
const tangent = getTangentAtLength(path, progress * pathLength);
|
||||
const angle = Math.atan2(tangent.y, tangent.x);
|
||||
|
||||
<g
|
||||
style={{
|
||||
transform: `translate(${point.x}px, ${point.y}px) rotate(${angle}rad)`,
|
||||
transformOrigin: "0 0",
|
||||
}}
|
||||
>
|
||||
<polygon points="0,0 -20,-10 -20,10" fill="#FF3232" />
|
||||
</g>;
|
||||
```
|
||||
141
.agents/skills/remotion-best-practices/rules/compositions.md
Normal file
141
.agents/skills/remotion-best-practices/rules/compositions.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: compositions
|
||||
description: Defining compositions, stills, folders, default props and dynamic metadata
|
||||
metadata:
|
||||
tags: composition, still, folder, props, metadata
|
||||
---
|
||||
|
||||
A `<Composition>` defines the component, width, height, fps and duration of a renderable video.
|
||||
|
||||
It normally is placed in the `src/Root.tsx` file.
|
||||
|
||||
```tsx
|
||||
import {Composition} from 'remotion';
|
||||
import {MyComposition} from './MyComposition';
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return <Composition id="MyComposition" component={MyComposition} durationInFrames={100} fps={30} width={1080} height={1080} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Default Props
|
||||
|
||||
Pass `defaultProps` to provide initial values for your component.
|
||||
Values must be JSON-serializable (`Date`, `Map`, `Set`, and `staticFile()` are supported).
|
||||
|
||||
```tsx
|
||||
import {Composition} from 'remotion';
|
||||
import {MyComposition, MyCompositionProps} from './MyComposition';
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100}
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={
|
||||
{
|
||||
title: 'Hello World',
|
||||
color: '#ff0000',
|
||||
} satisfies MyCompositionProps
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Use `type` declarations for props rather than `interface` to ensure `defaultProps` type safety.
|
||||
|
||||
## Folders
|
||||
|
||||
Use `<Folder>` to organize compositions in the sidebar.
|
||||
Folder names can only contain letters, numbers, and hyphens.
|
||||
|
||||
```tsx
|
||||
import {Composition, Folder} from 'remotion';
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<>
|
||||
<Folder name="Marketing">
|
||||
<Composition id="Promo" /* ... */ />
|
||||
<Composition id="Ad" /* ... */ />
|
||||
</Folder>
|
||||
<Folder name="Social">
|
||||
<Folder name="Instagram">
|
||||
<Composition id="Story" /* ... */ />
|
||||
<Composition id="Reel" /* ... */ />
|
||||
</Folder>
|
||||
</Folder>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Stills
|
||||
|
||||
Use `<Still>` for single-frame images. It does not require `durationInFrames` or `fps`.
|
||||
|
||||
```tsx
|
||||
import {Still} from 'remotion';
|
||||
import {Thumbnail} from './Thumbnail';
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return <Still id="Thumbnail" component={Thumbnail} width={1280} height={720} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Calculate Metadata
|
||||
|
||||
Use `calculateMetadata` to make dimensions, duration, or props dynamic based on data.
|
||||
|
||||
```tsx
|
||||
import {Composition, CalculateMetadataFunction} from 'remotion';
|
||||
import {MyComposition, MyCompositionProps} from './MyComposition';
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<MyCompositionProps> = async ({props, abortSignal}) => {
|
||||
const data = await fetch(`https://api.example.com/video/${props.videoId}`, {
|
||||
signal: abortSignal,
|
||||
}).then((res) => res.json());
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(data.duration * 30),
|
||||
props: {
|
||||
...props,
|
||||
videoUrl: data.url,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="MyComposition"
|
||||
component={MyComposition}
|
||||
durationInFrames={100} // Placeholder, will be overridden
|
||||
fps={30}
|
||||
width={1080}
|
||||
height={1080}
|
||||
defaultProps={{videoId: 'abc123'}}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The function can return `props`, `durationInFrames`, `width`, `height`, `fps`, and codec-related defaults. It runs once before rendering begins.
|
||||
|
||||
## Nesting compositions within another
|
||||
|
||||
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
|
||||
<CompositionComponent />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
```
|
||||
184
.agents/skills/remotion-best-practices/rules/display-captions.md
Normal file
184
.agents/skills/remotion-best-practices/rules/display-captions.md
Normal file
@@ -0,0 +1,184 @@
|
||||
---
|
||||
name: display-captions
|
||||
description: Displaying captions in Remotion with TikTok-style pages and word highlighting
|
||||
metadata:
|
||||
tags: captions, subtitles, display, tiktok, highlight
|
||||
---
|
||||
|
||||
# Displaying captions in Remotion
|
||||
|
||||
This guide explains how to display captions in Remotion, assuming you already have captions in the [`Caption`](https://www.remotion.dev/docs/captions/caption) format.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Read [Transcribing audio](transcribe-captions.md) for how to generate captions.
|
||||
|
||||
First, the [`@remotion/captions`](https://www.remotion.dev/docs/captions) package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/captions
|
||||
```
|
||||
|
||||
## Fetching captions
|
||||
|
||||
First, fetch your captions JSON file. Use [`useDelayRender()`](https://www.remotion.dev/docs/use-delay-render) to hold the render until the captions are loaded:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||
const { delayRender, continueRender, cancelRender } = useDelayRender();
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
const fetchCaptions = useCallback(async () => {
|
||||
try {
|
||||
// Assuming captions.json is in the public/ folder.
|
||||
const response = await fetch(staticFile("captions123.json"));
|
||||
const data = await response.json();
|
||||
setCaptions(data);
|
||||
continueRender(handle);
|
||||
} catch (e) {
|
||||
cancelRender(e);
|
||||
}
|
||||
}, [continueRender, cancelRender, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptions();
|
||||
}, [fetchCaptions]);
|
||||
|
||||
if (!captions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AbsoluteFill>{/* Render captions here */}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
|
||||
## Creating pages
|
||||
|
||||
Use `createTikTokStyleCaptions()` to group captions into pages. The `combineTokensWithinMilliseconds` option controls how many words appear at once:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from "react";
|
||||
import { createTikTokStyleCaptions } from "@remotion/captions";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
// How often captions should switch (in milliseconds)
|
||||
// Higher values = more words per page
|
||||
// Lower values = fewer words (more word-by-word)
|
||||
const SWITCH_CAPTIONS_EVERY_MS = 1200;
|
||||
|
||||
const { pages } = useMemo(() => {
|
||||
return createTikTokStyleCaptions({
|
||||
captions,
|
||||
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
|
||||
});
|
||||
}, [captions]);
|
||||
```
|
||||
|
||||
## Rendering with Sequences
|
||||
|
||||
Map over the pages and render each one in a `<Sequence>`. Calculate the start frame and duration from the page timing:
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig, AbsoluteFill } from "remotion";
|
||||
import type { TikTokPage } from "@remotion/captions";
|
||||
|
||||
const CaptionedContent: React.FC = () => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{pages.map((page, index) => {
|
||||
const nextPage = pages[index + 1] ?? null;
|
||||
const startFrame = (page.startMs / 1000) * fps;
|
||||
const endFrame = Math.min(
|
||||
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
|
||||
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
|
||||
);
|
||||
const durationInFrames = endFrame - startFrame;
|
||||
|
||||
if (durationInFrames <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sequence
|
||||
key={index}
|
||||
from={startFrame}
|
||||
durationInFrames={durationInFrames}
|
||||
>
|
||||
<CaptionPage page={page} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## White-space preservation
|
||||
|
||||
The captions are whitespace sensitive. You should include spaces in the `text` field before each word. Use `whiteSpace: "pre"` to preserve the whitespace in the captions.
|
||||
|
||||
## Separate component for captions
|
||||
|
||||
Put captioning logic in a separate component.
|
||||
Make a new file for it.
|
||||
|
||||
## Word highlighting
|
||||
|
||||
A caption page contains `tokens` which you can use to highlight the currently spoken word:
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { TikTokPage } from "@remotion/captions";
|
||||
|
||||
const HIGHLIGHT_COLOR = "#39E508";
|
||||
|
||||
const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Current time relative to the start of the sequence
|
||||
const currentTimeMs = (frame / fps) * 1000;
|
||||
// Convert to absolute time by adding the page start
|
||||
const absoluteTimeMs = page.startMs + currentTimeMs;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<div style={{ fontSize: 80, fontWeight: "bold", whiteSpace: "pre" }}>
|
||||
{page.tokens.map((token) => {
|
||||
const isActive =
|
||||
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={token.fromMs}
|
||||
style={{ color: isActive ? HIGHLIGHT_COLOR : "white" }}
|
||||
>
|
||||
{token.text}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Display captions alongside video content
|
||||
|
||||
By default, put the captions alongside the video content, so the captions are in sync.
|
||||
For each video, make a new captions JSON file.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Video src={staticFile("video.mp4")} />
|
||||
<CaptionPage page={page} />
|
||||
</AbsoluteFill>
|
||||
```
|
||||
229
.agents/skills/remotion-best-practices/rules/extract-frames.md
Normal file
229
.agents/skills/remotion-best-practices/rules/extract-frames.md
Normal file
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: extract-frames
|
||||
description: Extract frames from videos at specific timestamps using Mediabunny
|
||||
metadata:
|
||||
tags: frames, extract, video, thumbnail, filmstrip, canvas
|
||||
---
|
||||
|
||||
# Extracting frames from videos
|
||||
|
||||
Use Mediabunny to extract frames from videos at specific timestamps. This is useful for generating thumbnails, filmstrips, or processing individual frames.
|
||||
|
||||
## The `extractFrames()` function
|
||||
|
||||
This function can be copy-pasted into any project.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
Input,
|
||||
UrlSource,
|
||||
VideoSample,
|
||||
VideoSampleSink,
|
||||
} from "mediabunny";
|
||||
|
||||
type Options = {
|
||||
track: { width: number; height: number };
|
||||
container: string;
|
||||
durationInSeconds: number | null;
|
||||
};
|
||||
|
||||
export type ExtractFramesTimestampsInSecondsFn = (
|
||||
options: Options
|
||||
) => Promise<number[]> | number[];
|
||||
|
||||
export type ExtractFramesProps = {
|
||||
src: string;
|
||||
timestampsInSeconds: number[] | ExtractFramesTimestampsInSecondsFn;
|
||||
onVideoSample: (sample: VideoSample) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function extractFrames({
|
||||
src,
|
||||
timestampsInSeconds,
|
||||
onVideoSample,
|
||||
signal,
|
||||
}: ExtractFramesProps): Promise<void> {
|
||||
using input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src),
|
||||
});
|
||||
|
||||
const [durationInSeconds, format, videoTrack] = await Promise.all([
|
||||
input.computeDuration(),
|
||||
input.getFormat(),
|
||||
input.getPrimaryVideoTrack(),
|
||||
]);
|
||||
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found in the input");
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
|
||||
const timestamps =
|
||||
typeof timestampsInSeconds === "function"
|
||||
? await timestampsInSeconds({
|
||||
track: {
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
},
|
||||
container: format.name,
|
||||
durationInSeconds,
|
||||
})
|
||||
: timestampsInSeconds;
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Aborted");
|
||||
}
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
for await (using videoSample of sink.samplesAtTimestamps(timestamps)) {
|
||||
if (signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!videoSample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
onVideoSample(videoSample);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Basic usage
|
||||
|
||||
Extract frames at specific timestamps:
|
||||
|
||||
```tsx
|
||||
await extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||
onVideoSample: (sample) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = sample.displayWidth;
|
||||
canvas.height = sample.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
sample.draw(ctx!, 0, 0);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Creating a filmstrip
|
||||
|
||||
Use a callback function to dynamically calculate timestamps based on video metadata:
|
||||
|
||||
```tsx
|
||||
const canvasWidth = 500;
|
||||
const canvasHeight = 80;
|
||||
const fromSeconds = 0;
|
||||
const toSeconds = 10;
|
||||
|
||||
await extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: async ({ track, durationInSeconds }) => {
|
||||
const aspectRatio = track.width / track.height;
|
||||
const amountOfFramesFit = Math.ceil(
|
||||
canvasWidth / (canvasHeight * aspectRatio)
|
||||
);
|
||||
const segmentDuration = toSeconds - fromSeconds;
|
||||
const timestamps: number[] = [];
|
||||
|
||||
for (let i = 0; i < amountOfFramesFit; i++) {
|
||||
timestamps.push(
|
||||
fromSeconds + (segmentDuration / amountOfFramesFit) * (i + 0.5)
|
||||
);
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
},
|
||||
onVideoSample: (sample) => {
|
||||
console.log(`Frame at ${sample.timestamp}s`);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = sample.displayWidth;
|
||||
canvas.height = sample.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
sample.draw(ctx!, 0, 0);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Cancellation with AbortSignal
|
||||
|
||||
Cancel frame extraction after a timeout:
|
||||
|
||||
```tsx
|
||||
const controller = new AbortController();
|
||||
|
||||
setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||
onVideoSample: (sample) => {
|
||||
using frame = sample;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = frame.displayWidth;
|
||||
canvas.height = frame.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
frame.draw(ctx!, 0, 0);
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
console.log("Frame extraction complete!");
|
||||
} catch (error) {
|
||||
console.error("Frame extraction was aborted or failed:", error);
|
||||
}
|
||||
```
|
||||
|
||||
## Timeout with Promise.race
|
||||
|
||||
```tsx
|
||||
const controller = new AbortController();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error("Frame extraction timed out after 10 seconds"));
|
||||
}, 10000);
|
||||
|
||||
controller.signal.addEventListener("abort", () => clearTimeout(timeoutId), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
extractFrames({
|
||||
src: "https://remotion.media/video.mp4",
|
||||
timestampsInSeconds: [0, 1, 2, 3, 4],
|
||||
onVideoSample: (sample) => {
|
||||
using frame = sample;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = frame.displayWidth;
|
||||
canvas.height = frame.displayHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
frame.draw(ctx!, 0, 0);
|
||||
},
|
||||
signal: controller.signal,
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
console.log("Frame extraction complete!");
|
||||
} catch (error) {
|
||||
console.error("Frame extraction was aborted or failed:", error);
|
||||
}
|
||||
```
|
||||
38
.agents/skills/remotion-best-practices/rules/ffmpeg.md
Normal file
38
.agents/skills/remotion-best-practices/rules/ffmpeg.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: ffmpeg
|
||||
description: Using FFmpeg and FFprobe in Remotion
|
||||
metadata:
|
||||
tags: ffmpeg, ffprobe, video, trimming
|
||||
---
|
||||
|
||||
## FFmpeg in Remotion
|
||||
|
||||
`ffmpeg` and `ffprobe` do not need to be installed. They are available via the `bunx remotion ffmpeg` and `bunx remotion ffprobe`:
|
||||
|
||||
```bash
|
||||
bunx remotion ffmpeg -i input.mp4 output.mp3
|
||||
bunx remotion ffprobe input.mp4
|
||||
```
|
||||
|
||||
### Trimming videos
|
||||
|
||||
You have 2 options for trimming videos:
|
||||
|
||||
1. Use the FFMpeg command line. You MUST re-encode the video to avoid frozen frames at the start of the video.
|
||||
|
||||
```bash
|
||||
# Re-encodes from the exact frame
|
||||
bunx remotion ffmpeg -ss 00:00:05 -i public/input.mp4 -to 00:00:10 -c:v libx264 -c:a aac public/output.mp4
|
||||
```
|
||||
|
||||
2. Use the `trimBefore` and `trimAfter` props of the `<Video>` component. The benefit is that this is non-destructive and you can change the trim at any time.
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
trimBefore={5 * fps}
|
||||
trimAfter={10 * fps}
|
||||
/>;
|
||||
```
|
||||
152
.agents/skills/remotion-best-practices/rules/fonts.md
Normal file
152
.agents/skills/remotion-best-practices/rules/fonts.md
Normal file
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: fonts
|
||||
description: Loading Google Fonts and local fonts in Remotion
|
||||
metadata:
|
||||
tags: fonts, google-fonts, typography, text
|
||||
---
|
||||
|
||||
# Using fonts in Remotion
|
||||
|
||||
## Google Fonts with @remotion/google-fonts
|
||||
|
||||
The recommended way to use Google Fonts. It's type-safe and automatically blocks rendering until the font is ready.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First, the @remotion/google-fonts package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/google-fonts # If project uses npm
|
||||
bunx remotion add @remotion/google-fonts # If project uses bun
|
||||
yarn remotion add @remotion/google-fonts # If project uses yarn
|
||||
pnpm exec remotion add @remotion/google-fonts # If project uses pnpm
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||
|
||||
const { fontFamily } = loadFont();
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <div style={{ fontFamily }}>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
Preferrably, specify only needed weights and subsets to reduce file size:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Roboto";
|
||||
|
||||
const { fontFamily } = loadFont("normal", {
|
||||
weights: ["400", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
```
|
||||
|
||||
### Waiting for font to load
|
||||
|
||||
Use `waitUntilDone()` if you need to know when the font is ready:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Lobster";
|
||||
|
||||
const { fontFamily, waitUntilDone } = loadFont();
|
||||
|
||||
await waitUntilDone();
|
||||
```
|
||||
|
||||
## Local fonts with @remotion/fonts
|
||||
|
||||
For local font files, use the `@remotion/fonts` package.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
First, install @remotion/fonts:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/fonts # If project uses npm
|
||||
bunx remotion add @remotion/fonts # If project uses bun
|
||||
yarn remotion add @remotion/fonts # If project uses yarn
|
||||
pnpm exec remotion add @remotion/fonts # If project uses pnpm
|
||||
```
|
||||
|
||||
### Loading a local font
|
||||
|
||||
Place your font file in the `public/` folder and use `loadFont()`:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/fonts";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
await loadFont({
|
||||
family: "MyFont",
|
||||
url: staticFile("MyFont-Regular.woff2"),
|
||||
});
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <div style={{ fontFamily: "MyFont" }}>Hello World</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Loading multiple weights
|
||||
|
||||
Load each weight separately with the same family name:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/fonts";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
await Promise.all([
|
||||
loadFont({
|
||||
family: "Inter",
|
||||
url: staticFile("Inter-Regular.woff2"),
|
||||
weight: "400",
|
||||
}),
|
||||
loadFont({
|
||||
family: "Inter",
|
||||
url: staticFile("Inter-Bold.woff2"),
|
||||
weight: "700",
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
### Available options
|
||||
|
||||
```tsx
|
||||
loadFont({
|
||||
family: "MyFont", // Required: name to use in CSS
|
||||
url: staticFile("font.woff2"), // Required: font file URL
|
||||
format: "woff2", // Optional: auto-detected from extension
|
||||
weight: "400", // Optional: font weight
|
||||
style: "normal", // Optional: normal or italic
|
||||
display: "block", // Optional: font-display behavior
|
||||
});
|
||||
```
|
||||
|
||||
## Using in components
|
||||
|
||||
Call `loadFont()` at the top level of your component or in a separate file that's imported early:
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Montserrat";
|
||||
|
||||
const { fontFamily } = loadFont("normal", {
|
||||
weights: ["400", "700"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const Title: React.FC<{ text: string }> = ({ text }) => {
|
||||
return (
|
||||
<h1
|
||||
style={{
|
||||
fontFamily,
|
||||
fontSize: 80,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: get-audio-duration
|
||||
description: Getting the duration of an audio file in seconds with Mediabunny
|
||||
metadata:
|
||||
tags: duration, audio, length, time, seconds, mp3, wav
|
||||
---
|
||||
|
||||
# Getting audio duration with Mediabunny
|
||||
|
||||
Mediabunny can extract the duration of an audio file. It works in browser, Node.js, and Bun environments.
|
||||
|
||||
## Getting audio duration
|
||||
|
||||
```tsx title="get-audio-duration.ts"
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const getAudioDuration = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
const durationInSeconds = await input.computeDuration();
|
||||
return durationInSeconds;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const duration = await getAudioDuration("https://remotion.media/audio.mp3");
|
||||
console.log(duration); // e.g. 180.5 (seconds)
|
||||
```
|
||||
|
||||
## Using with staticFile in Remotion
|
||||
|
||||
Make sure to wrap the file path in `staticFile()`:
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getAudioDuration(staticFile("audio.mp3"));
|
||||
```
|
||||
|
||||
## In Node.js and Bun
|
||||
|
||||
Use `FileSource` instead of `UrlSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new FileSource(file), // File object from input or drag-drop
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: get-video-dimensions
|
||||
description: Getting the width and height of a video file with Mediabunny
|
||||
metadata:
|
||||
tags: dimensions, width, height, resolution, size, video
|
||||
---
|
||||
|
||||
# Getting video dimensions with Mediabunny
|
||||
|
||||
Mediabunny can extract the width and height of a video file. It works in browser, Node.js, and Bun environments.
|
||||
|
||||
## Getting video dimensions
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const getVideoDimensions = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found");
|
||||
}
|
||||
|
||||
return {
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const dimensions = await getVideoDimensions("https://remotion.media/video.mp4");
|
||||
console.log(dimensions.width); // e.g. 1920
|
||||
console.log(dimensions.height); // e.g. 1080
|
||||
```
|
||||
|
||||
## Using with local files
|
||||
|
||||
For local files, use `FileSource` instead of `UrlSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new FileSource(file), // File object from input or drag-drop
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const width = videoTrack.displayWidth;
|
||||
const height = videoTrack.displayHeight;
|
||||
```
|
||||
|
||||
## Using with staticFile in Remotion
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const dimensions = await getVideoDimensions(staticFile("video.mp4"));
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: get-video-duration
|
||||
description: Getting the duration of a video file in seconds with Mediabunny
|
||||
metadata:
|
||||
tags: duration, video, length, time, seconds
|
||||
---
|
||||
|
||||
# Getting video duration with Mediabunny
|
||||
|
||||
Mediabunny can extract the duration of a video file. It works in browser, Node.js, and Bun environments.
|
||||
|
||||
## Getting video duration
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, UrlSource } from "mediabunny";
|
||||
|
||||
export const getVideoDuration = async (src: string) => {
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new UrlSource(src, {
|
||||
getRetryDelay: () => null,
|
||||
}),
|
||||
});
|
||||
|
||||
const durationInSeconds = await input.computeDuration();
|
||||
return durationInSeconds;
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const duration = await getVideoDuration("https://remotion.media/video.mp4");
|
||||
console.log(duration); // e.g. 10.5 (seconds)
|
||||
```
|
||||
|
||||
## Video files from the public/ directory
|
||||
|
||||
Make sure to wrap the file path in `staticFile()`:
|
||||
|
||||
```tsx
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getVideoDuration(staticFile("video.mp4"));
|
||||
```
|
||||
|
||||
## In Node.js and Bun
|
||||
|
||||
Use `FileSource` instead of `UrlSource`:
|
||||
|
||||
```tsx
|
||||
import { Input, ALL_FORMATS, FileSource } from "mediabunny";
|
||||
|
||||
const input = new Input({
|
||||
formats: ALL_FORMATS,
|
||||
source: new FileSource(file), // File object from input or drag-drop
|
||||
});
|
||||
|
||||
const durationInSeconds = await input.computeDuration();
|
||||
```
|
||||
141
.agents/skills/remotion-best-practices/rules/gifs.md
Normal file
141
.agents/skills/remotion-best-practices/rules/gifs.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: gif
|
||||
description: Displaying GIFs, APNG, AVIF and WebP in Remotion
|
||||
metadata:
|
||||
tags: gif, animation, images, animated, apng, avif, webp
|
||||
---
|
||||
|
||||
# Using Animated images in Remotion
|
||||
|
||||
## Basic usage
|
||||
|
||||
Use `<AnimatedImage>` to display a GIF, APNG, AVIF or WebP image synchronized with Remotion's timeline:
|
||||
|
||||
```tsx
|
||||
import { AnimatedImage, staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return (
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} />
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported (must have CORS enabled):
|
||||
|
||||
```tsx
|
||||
<AnimatedImage
|
||||
src="https://example.com/animation.gif"
|
||||
width={500}
|
||||
height={500}
|
||||
/>
|
||||
```
|
||||
|
||||
## Sizing and fit
|
||||
|
||||
Control how the image fills its container with the `fit` prop:
|
||||
|
||||
```tsx
|
||||
// Stretch to fill (default)
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="fill" />
|
||||
|
||||
// Maintain aspect ratio, fit inside container
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="contain" />
|
||||
|
||||
// Fill container, crop if needed
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={300} fit="cover" />
|
||||
```
|
||||
|
||||
## Playback speed
|
||||
|
||||
Use `playbackRate` to control the animation speed:
|
||||
|
||||
```tsx
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={2} /> {/* 2x speed */}
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
## Looping behavior
|
||||
|
||||
Control what happens when the animation finishes:
|
||||
|
||||
```tsx
|
||||
// Loop indefinitely (default)
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="loop" />
|
||||
|
||||
// Play once, show final frame
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="pause-after-finish" />
|
||||
|
||||
// Play once, then clear canvas
|
||||
<AnimatedImage src={staticFile("animation.gif")} width={500} height={500} loopBehavior="clear-after-finish" />
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Use the `style` prop for additional CSS (use `width` and `height` props for sizing):
|
||||
|
||||
```tsx
|
||||
<AnimatedImage
|
||||
src={staticFile("animation.gif")}
|
||||
width={500}
|
||||
height={500}
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Getting GIF duration
|
||||
|
||||
Use `getGifDurationInSeconds()` from `@remotion/gif` to get the duration of a GIF.
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/gif
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { getGifDurationInSeconds } from "@remotion/gif";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
|
||||
console.log(duration); // e.g. 2.5
|
||||
```
|
||||
|
||||
This is useful for setting the composition duration to match the GIF:
|
||||
|
||||
```tsx
|
||||
import { getGifDurationInSeconds } from "@remotion/gif";
|
||||
import { staticFile, CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||
const duration = await getGifDurationInSeconds(staticFile("animation.gif"));
|
||||
return {
|
||||
durationInFrames: Math.ceil(duration * 30),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Alternative
|
||||
|
||||
If `<AnimatedImage>` does not work (only supported in Chrome and Firefox), you can use `<Gif>` from `@remotion/gif` instead.
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/gif # If project uses npm
|
||||
bunx remotion add @remotion/gif # If project uses bun
|
||||
yarn remotion add @remotion/gif # If project uses yarn
|
||||
pnpm exec remotion add @remotion/gif # If project uses pnpm
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Gif } from "@remotion/gif";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Gif src={staticFile("animation.gif")} width={500} height={500} />;
|
||||
};
|
||||
```
|
||||
|
||||
The `<Gif>` component has the same props as `<AnimatedImage>` but only supports GIF files.
|
||||
130
.agents/skills/remotion-best-practices/rules/images.md
Normal file
130
.agents/skills/remotion-best-practices/rules/images.md
Normal file
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: images
|
||||
description: Embedding images in Remotion using the <Img> component
|
||||
metadata:
|
||||
tags: images, img, staticFile, png, jpg, svg, webp
|
||||
---
|
||||
|
||||
# Using images in Remotion
|
||||
|
||||
## The `<Img>` component
|
||||
|
||||
Always use the `<Img>` component from `remotion` to display images:
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Img src={staticFile("photo.png")} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Important restrictions
|
||||
|
||||
**You MUST use the `<Img>` component from `remotion`.** Do not use:
|
||||
|
||||
- Native HTML `<img>` elements
|
||||
- Next.js `<Image>` component
|
||||
- CSS `background-image`
|
||||
|
||||
The `<Img>` component ensures images are fully loaded before rendering, preventing flickering and blank frames during video export.
|
||||
|
||||
## Local images with staticFile()
|
||||
|
||||
Place images in the `public/` folder and use `staticFile()` to reference them:
|
||||
|
||||
```
|
||||
my-video/
|
||||
├─ public/
|
||||
│ ├─ logo.png
|
||||
│ ├─ avatar.jpg
|
||||
│ └─ icon.svg
|
||||
├─ src/
|
||||
├─ package.json
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile } from "remotion";
|
||||
|
||||
<Img src={staticFile("logo.png")} />
|
||||
```
|
||||
|
||||
## Remote images
|
||||
|
||||
Remote URLs can be used directly without `staticFile()`:
|
||||
|
||||
```tsx
|
||||
<Img src="https://example.com/image.png" />
|
||||
```
|
||||
|
||||
Ensure remote images have CORS enabled.
|
||||
|
||||
For animated GIFs, use the `<Gif>` component from `@remotion/gif` instead.
|
||||
|
||||
## Sizing and positioning
|
||||
|
||||
Use the `style` prop to control size and position:
|
||||
|
||||
```tsx
|
||||
<Img
|
||||
src={staticFile("photo.png")}
|
||||
style={{
|
||||
width: 500,
|
||||
height: 300,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Dynamic image paths
|
||||
|
||||
Use template literals for dynamic file references:
|
||||
|
||||
```tsx
|
||||
import { Img, staticFile, useCurrentFrame } from "remotion";
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Image sequence
|
||||
<Img src={staticFile(`frames/frame${frame}.png`)} />
|
||||
|
||||
// Selecting based on props
|
||||
<Img src={staticFile(`avatars/${props.userId}.png`)} />
|
||||
|
||||
// Conditional images
|
||||
<Img src={staticFile(`icons/${isActive ? "active" : "inactive"}.svg`)} />
|
||||
```
|
||||
|
||||
This pattern is useful for:
|
||||
|
||||
- Image sequences (frame-by-frame animations)
|
||||
- User-specific avatars or profile images
|
||||
- Theme-based icons
|
||||
- State-dependent graphics
|
||||
|
||||
## Getting image dimensions
|
||||
|
||||
Use `getImageDimensions()` to get the dimensions of an image:
|
||||
|
||||
```tsx
|
||||
import { getImageDimensions, staticFile } from "remotion";
|
||||
|
||||
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||
```
|
||||
|
||||
This is useful for calculating aspect ratios or sizing compositions:
|
||||
|
||||
```tsx
|
||||
import { getImageDimensions, staticFile, CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction = async () => {
|
||||
const { width, height } = await getImageDimensions(staticFile("photo.png"));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
name: import-srt-captions
|
||||
description: Importing .srt subtitle files into Remotion using @remotion/captions
|
||||
metadata:
|
||||
tags: captions, subtitles, srt, import, parse
|
||||
---
|
||||
|
||||
# Importing .srt subtitles into Remotion
|
||||
|
||||
If you have an existing `.srt` subtitle file, you can import it into Remotion using `parseSrt()` from `@remotion/captions`.
|
||||
|
||||
If you don't have a .srt file, read [Transcribing audio](transcribe-captions.md) for how to generate captions instead.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/captions package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/captions # If project uses npm
|
||||
bunx remotion add @remotion/captions # If project uses bun
|
||||
yarn remotion add @remotion/captions # If project uses yarn
|
||||
pnpm exec remotion add @remotion/captions # If project uses pnpm
|
||||
```
|
||||
|
||||
## Reading an .srt file
|
||||
|
||||
Use `staticFile()` to reference an `.srt` file in your `public` folder, then fetch and parse it:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AbsoluteFill, staticFile, useDelayRender } from "remotion";
|
||||
import { parseSrt } from "@remotion/captions";
|
||||
import type { Caption } from "@remotion/captions";
|
||||
|
||||
export const MyComponent: React.FC = () => {
|
||||
const [captions, setCaptions] = useState<Caption[] | null>(null);
|
||||
const { delayRender, continueRender, cancelRender } = useDelayRender();
|
||||
const [handle] = useState(() => delayRender());
|
||||
|
||||
const fetchCaptions = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(staticFile("subtitles.srt"));
|
||||
const text = await response.text();
|
||||
const { captions: parsed } = parseSrt({ input: text });
|
||||
setCaptions(parsed);
|
||||
continueRender(handle);
|
||||
} catch (e) {
|
||||
cancelRender(e);
|
||||
}
|
||||
}, [continueRender, cancelRender, handle]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCaptions();
|
||||
}, [fetchCaptions]);
|
||||
|
||||
if (!captions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AbsoluteFill>{/* Use captions here */}</AbsoluteFill>;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported - you can `fetch()` a remote file via URL instead of using `staticFile()`.
|
||||
|
||||
## Using imported captions
|
||||
|
||||
Once parsed, the captions are in the `Caption` format and can be used with all `@remotion/captions` utilities.
|
||||
73
.agents/skills/remotion-best-practices/rules/light-leaks.md
Normal file
73
.agents/skills/remotion-best-practices/rules/light-leaks.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: light-leaks
|
||||
description: Light leak overlay effects for Remotion using @remotion/light-leaks.
|
||||
metadata:
|
||||
tags: light-leaks, overlays, effects, transitions
|
||||
---
|
||||
|
||||
## Light Leaks
|
||||
|
||||
This only works from Remotion 4.0.415 and up. Use `npx remotion versions` to check your Remotion version and `npx remotion upgrade` to upgrade your Remotion version.
|
||||
|
||||
`<LightLeak>` from `@remotion/light-leaks` renders a WebGL-based light leak effect. It reveals during the first half of its duration and retracts during the second half.
|
||||
|
||||
Typically used inside a `<TransitionSeries.Overlay>` to play over the cut point between two scenes. See the **transitions** rule for `<TransitionSeries>` and overlay usage.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/light-leaks
|
||||
```
|
||||
|
||||
## Basic usage with TransitionSeries
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries } from "@remotion/transitions";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={30}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
- `durationInFrames?` — defaults to the parent sequence/composition duration. The effect reveals during the first half and retracts during the second half.
|
||||
- `seed?` — determines the shape of the light leak pattern. Different seeds produce different patterns. Default: `0`.
|
||||
- `hueShift?` — rotates the hue in degrees (`0`–`360`). Default: `0` (yellow-to-orange). `120` = green, `240` = blue.
|
||||
|
||||
## Customizing the look
|
||||
|
||||
```tsx
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
// Blue-tinted light leak with a different pattern
|
||||
<LightLeak seed={5} hueShift={240} />;
|
||||
|
||||
// Green-tinted light leak
|
||||
<LightLeak seed={2} hueShift={120} />;
|
||||
```
|
||||
|
||||
## Standalone usage
|
||||
|
||||
`<LightLeak>` can also be used outside of `<TransitionSeries>`, for example as a decorative overlay in any composition:
|
||||
|
||||
```tsx
|
||||
import { AbsoluteFill } from "remotion";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
const MyComp: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<MyContent />
|
||||
<LightLeak durationInFrames={60} seed={3} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
```
|
||||
68
.agents/skills/remotion-best-practices/rules/lottie.md
Normal file
68
.agents/skills/remotion-best-practices/rules/lottie.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: lottie
|
||||
description: Embedding Lottie animations in Remotion.
|
||||
metadata:
|
||||
category: Animation
|
||||
---
|
||||
|
||||
# Using Lottie Animations in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/lottie package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/lottie # If project uses npm
|
||||
bunx remotion add @remotion/lottie # If project uses bun
|
||||
yarn remotion add @remotion/lottie # If project uses yarn
|
||||
pnpm exec remotion add @remotion/lottie # If project uses pnpm
|
||||
```
|
||||
|
||||
## Displaying a Lottie file
|
||||
|
||||
To import a Lottie animation:
|
||||
|
||||
- Fetch the Lottie asset
|
||||
- Wrap the loading process in `delayRender()` and `continueRender()`
|
||||
- Save the animation data in a state
|
||||
- Render the Lottie animation using the `Lottie` component from the `@remotion/lottie` package
|
||||
|
||||
```tsx
|
||||
import {Lottie, LottieAnimationData} from '@remotion/lottie';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {cancelRender, continueRender, delayRender} from 'remotion';
|
||||
|
||||
export const MyAnimation = () => {
|
||||
const [handle] = useState(() => delayRender('Loading Lottie animation'));
|
||||
|
||||
const [animationData, setAnimationData] = useState<LottieAnimationData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('https://assets4.lottiefiles.com/packages/lf20_zyquagfl.json')
|
||||
.then((data) => data.json())
|
||||
.then((json) => {
|
||||
setAnimationData(json);
|
||||
continueRender(handle);
|
||||
})
|
||||
.catch((err) => {
|
||||
cancelRender(err);
|
||||
});
|
||||
}, [handle]);
|
||||
|
||||
if (!animationData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Lottie animationData={animationData} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Styling and animating
|
||||
|
||||
Lottie supports the `style` prop to allow styles and animations:
|
||||
|
||||
```tsx
|
||||
return <Lottie animationData={animationData} style={{width: 400, height: 400}} />;
|
||||
```
|
||||
|
||||
401
.agents/skills/remotion-best-practices/rules/maps.md
Normal file
401
.agents/skills/remotion-best-practices/rules/maps.md
Normal file
@@ -0,0 +1,401 @@
|
||||
---
|
||||
name: maps
|
||||
description: Make map animations with Mapbox
|
||||
metadata:
|
||||
tags: map, map animation, mapbox
|
||||
---
|
||||
|
||||
Maps can be added to a Remotion video with Mapbox.
|
||||
The [Mapbox documentation](https://docs.mapbox.com/mapbox-gl-js/api/) has the API reference.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Mapbox and `@turf/turf` need to be installed.
|
||||
|
||||
Search the project for lockfiles and run the correct command depending on the package manager:
|
||||
|
||||
If `package-lock.json` is found, use the following command:
|
||||
|
||||
```bash
|
||||
npm i mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
If `bun.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
bun i mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
If `yarn.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
yarn add mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
If `pnpm-lock.yaml` is found, use the following command:
|
||||
|
||||
```bash
|
||||
pnpm i mapbox-gl @turf/turf @types/mapbox-gl
|
||||
```
|
||||
|
||||
The user needs to create a free Mapbox account and create an access token by visiting https://console.mapbox.com/account/access-tokens/.
|
||||
|
||||
The mapbox token needs to be added to the `.env` file:
|
||||
|
||||
```txt title=".env"
|
||||
REMOTION_MAPBOX_TOKEN==pk.your-mapbox-access-token
|
||||
```
|
||||
|
||||
## Adding a map
|
||||
|
||||
Here is a basic example of a map in Remotion.
|
||||
|
||||
```tsx
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
|
||||
import mapboxgl, {Map} from 'mapbox-gl';
|
||||
|
||||
export const lineCoordinates = [
|
||||
[6.56158447265625, 46.059891147620725],
|
||||
[6.5691375732421875, 46.05679376154153],
|
||||
[6.5842437744140625, 46.05059898938315],
|
||||
[6.594886779785156, 46.04702502069337],
|
||||
[6.601066589355469, 46.0460718554722],
|
||||
[6.6089630126953125, 46.0365370783104],
|
||||
[6.6185760498046875, 46.018420689207964],
|
||||
];
|
||||
|
||||
mapboxgl.accessToken = process.env.REMOTION_MAPBOX_TOKEN as string;
|
||||
|
||||
export const MyComposition = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
|
||||
const {width, height} = useVideoConfig();
|
||||
const [handle] = useState(() => delayRender('Loading map...'));
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const _map = new Map({
|
||||
container: ref.current!,
|
||||
zoom: 11.53,
|
||||
center: [6.5615, 46.0598],
|
||||
pitch: 65,
|
||||
bearing: 0,
|
||||
style: 'mapbox://styles/mapbox/standard',
|
||||
interactive: false,
|
||||
fadeDuration: 0,
|
||||
});
|
||||
|
||||
_map.on('style.load', () => {
|
||||
// Hide all features from the Mapbox Standard style
|
||||
const hideFeatures = [
|
||||
'showRoadsAndTransit',
|
||||
'showRoads',
|
||||
'showTransit',
|
||||
'showPedestrianRoads',
|
||||
'showRoadLabels',
|
||||
'showTransitLabels',
|
||||
'showPlaceLabels',
|
||||
'showPointOfInterestLabels',
|
||||
'showPointsOfInterest',
|
||||
'showAdminBoundaries',
|
||||
'showLandmarkIcons',
|
||||
'showLandmarkIconLabels',
|
||||
'show3dObjects',
|
||||
'show3dBuildings',
|
||||
'show3dTrees',
|
||||
'show3dLandmarks',
|
||||
'show3dFacades',
|
||||
];
|
||||
for (const feature of hideFeatures) {
|
||||
_map.setConfigProperty('basemap', feature, false);
|
||||
}
|
||||
|
||||
_map.setConfigProperty('basemap', 'colorTrunks', 'rgba(0, 0, 0, 0)');
|
||||
|
||||
_map.addSource('trace', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: lineCoordinates,
|
||||
},
|
||||
},
|
||||
});
|
||||
_map.addLayer({
|
||||
type: 'line',
|
||||
source: 'trace',
|
||||
id: 'line',
|
||||
paint: {
|
||||
'line-color': 'black',
|
||||
'line-width': 5,
|
||||
},
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
_map.on('load', () => {
|
||||
continueRender(handle);
|
||||
setMap(_map);
|
||||
});
|
||||
}, [handle, lineCoordinates]);
|
||||
|
||||
const style: React.CSSProperties = useMemo(() => ({width, height, position: 'absolute'}), [width, height]);
|
||||
|
||||
return <AbsoluteFill ref={ref} style={style} />;
|
||||
};
|
||||
```
|
||||
|
||||
The following is important in Remotion:
|
||||
|
||||
- Animations must be driven by `useCurrentFrame()` and animations that Mapbox brings itself should be disabled. For example, the `fadeDuration` prop should be set to `0`, `interactive` should be set to `false`, etc.
|
||||
- Loading the map should be delayed using `useDelayRender()` and the map should be set to `null` until it is loaded.
|
||||
- The element containing the ref MUST have an explicit width and height and `position: "absolute"`.
|
||||
- Do not add a `_map.remove();` cleanup function.
|
||||
|
||||
## Drawing lines
|
||||
|
||||
Unless I request it, do not add a glow effect to the lines.
|
||||
Unless I request it, do not add additional points to the lines.
|
||||
|
||||
## Map style
|
||||
|
||||
By default, use the `mapbox://styles/mapbox/standard` style.
|
||||
Hide the labels from the base map style.
|
||||
|
||||
Unless I request otherwise, remove all features from the Mapbox Standard style.
|
||||
|
||||
```tsx
|
||||
// Hide all features from the Mapbox Standard style
|
||||
const hideFeatures = [
|
||||
'showRoadsAndTransit',
|
||||
'showRoads',
|
||||
'showTransit',
|
||||
'showPedestrianRoads',
|
||||
'showRoadLabels',
|
||||
'showTransitLabels',
|
||||
'showPlaceLabels',
|
||||
'showPointOfInterestLabels',
|
||||
'showPointsOfInterest',
|
||||
'showAdminBoundaries',
|
||||
'showLandmarkIcons',
|
||||
'showLandmarkIconLabels',
|
||||
'show3dObjects',
|
||||
'show3dBuildings',
|
||||
'show3dTrees',
|
||||
'show3dLandmarks',
|
||||
'show3dFacades',
|
||||
];
|
||||
for (const feature of hideFeatures) {
|
||||
_map.setConfigProperty('basemap', feature, false);
|
||||
}
|
||||
|
||||
_map.setConfigProperty('basemap', 'colorMotorways', 'transparent');
|
||||
_map.setConfigProperty('basemap', 'colorRoads', 'transparent');
|
||||
_map.setConfigProperty('basemap', 'colorTrunks', 'transparent');
|
||||
```
|
||||
|
||||
## Animating the camera
|
||||
|
||||
You can animate the camera along the line by adding a `useEffect` hook that updates the camera position based on the current frame.
|
||||
|
||||
Unless I ask for it, do not jump between camera angles.
|
||||
|
||||
```tsx
|
||||
import * as turf from '@turf/turf';
|
||||
import {interpolate} from 'remotion';
|
||||
import {Easing} from 'remotion';
|
||||
import {useCurrentFrame, useVideoConfig, useDelayRender} from 'remotion';
|
||||
|
||||
const animationDuration = 20;
|
||||
const cameraAltitude = 4000;
|
||||
```
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
const {delayRender, continueRender} = useDelayRender();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
const handle = delayRender('Moving point...');
|
||||
|
||||
const routeDistance = turf.length(turf.lineString(lineCoordinates));
|
||||
|
||||
const progress = interpolate(frame / fps, [0.00001, animationDuration], [0, 1], {
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
const camera = map.getFreeCameraOptions();
|
||||
|
||||
const alongRoute = turf.along(turf.lineString(lineCoordinates), routeDistance * progress).geometry.coordinates;
|
||||
|
||||
camera.lookAtPoint({
|
||||
lng: alongRoute[0],
|
||||
lat: alongRoute[1],
|
||||
});
|
||||
|
||||
map.setFreeCameraOptions(camera);
|
||||
map.once('idle', () => continueRender(handle));
|
||||
}, [lineCoordinates, fps, frame, handle, map]);
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
IMPORTANT: Keep the camera by default so north is up.
|
||||
IMPORTANT: For multi-step animations, set all properties at all stages (zoom, position, line progress) to prevent jumps. Override initial values.
|
||||
|
||||
- The progress is clamped to a minimum value to avoid the line being empty, which can lead to turf errors
|
||||
- See [Timing](./timing.md) for more options for timing.
|
||||
- Consider the dimensions of the composition and make the lines thick enough and the label font size large enough to be legible for when the composition is scaled down.
|
||||
|
||||
## Animating lines
|
||||
|
||||
### Straight lines (linear interpolation)
|
||||
|
||||
To animate a line that appears straight on the map, use linear interpolation between coordinates. Do NOT use turf's `lineSliceAlong` or `along` functions, as they use geodesic (great circle) calculations which appear curved on a Mercator projection.
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const {durationInFrames} = useVideoConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const animationHandle = delayRender('Animating line...');
|
||||
|
||||
const progress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.inOut(Easing.cubic),
|
||||
});
|
||||
|
||||
// Linear interpolation for a straight line on the map
|
||||
const start = lineCoordinates[0];
|
||||
const end = lineCoordinates[1];
|
||||
const currentLng = start[0] + (end[0] - start[0]) * progress;
|
||||
const currentLat = start[1] + (end[1] - start[1]) * progress;
|
||||
|
||||
const lineData: GeoJSON.Feature<GeoJSON.LineString> = {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [start, [currentLng, currentLat]],
|
||||
},
|
||||
};
|
||||
|
||||
const source = map.getSource('trace') as mapboxgl.GeoJSONSource;
|
||||
if (source) {
|
||||
source.setData(lineData);
|
||||
}
|
||||
|
||||
map.once('idle', () => continueRender(animationHandle));
|
||||
}, [frame, map, durationInFrames]);
|
||||
```
|
||||
|
||||
### Curved lines (geodesic/great circle)
|
||||
|
||||
To animate a line that follows the geodesic (great circle) path between two points, use turf's `lineSliceAlong`. This is useful for showing flight paths or the actual shortest distance on Earth.
|
||||
|
||||
```tsx
|
||||
import * as turf from '@turf/turf';
|
||||
|
||||
const routeLine = turf.lineString(lineCoordinates);
|
||||
const routeDistance = turf.length(routeLine);
|
||||
|
||||
const currentDistance = Math.max(0.001, routeDistance * progress);
|
||||
const slicedLine = turf.lineSliceAlong(routeLine, 0, currentDistance);
|
||||
|
||||
const source = map.getSource('route') as mapboxgl.GeoJSONSource;
|
||||
if (source) {
|
||||
source.setData(slicedLine);
|
||||
}
|
||||
```
|
||||
|
||||
## Markers
|
||||
|
||||
Add labels, and markers where appropriate.
|
||||
|
||||
```tsx
|
||||
_map.addSource('markers', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: {name: 'Point 1'},
|
||||
geometry: {type: 'Point', coordinates: [-118.2437, 34.0522]},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
_map.addLayer({
|
||||
id: 'city-markers',
|
||||
type: 'circle',
|
||||
source: 'markers',
|
||||
paint: {
|
||||
'circle-radius': 40,
|
||||
'circle-color': '#FF4444',
|
||||
'circle-stroke-width': 4,
|
||||
'circle-stroke-color': '#FFFFFF',
|
||||
},
|
||||
});
|
||||
|
||||
_map.addLayer({
|
||||
id: 'labels',
|
||||
type: 'symbol',
|
||||
source: 'markers',
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['DIN Pro Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 50,
|
||||
'text-offset': [0, 0.5],
|
||||
'text-anchor': 'top',
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#FFFFFF',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 2,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make sure they are big enough. Check the composition dimensions and scale the labels accordingly.
|
||||
For a composition size of 1920x1080, the label font size should be at least 40px.
|
||||
|
||||
IMPORTANT: Keep the `text-offset` small enough so it is close to the marker. Consider the marker circle radius. For a circle radius of 40, this is a good offset:
|
||||
|
||||
```tsx
|
||||
"text-offset": [0, 0.5],
|
||||
```
|
||||
|
||||
## 3D buildings
|
||||
|
||||
To enable 3D buildings, use the following code:
|
||||
|
||||
```tsx
|
||||
_map.setConfigProperty('basemap', 'show3dObjects', true);
|
||||
_map.setConfigProperty('basemap', 'show3dLandmarks', true);
|
||||
_map.setConfigProperty('basemap', 'show3dBuildings', true);
|
||||
```
|
||||
|
||||
## Rendering
|
||||
|
||||
When rendering a map animation, make sure to render with the following flags:
|
||||
|
||||
```
|
||||
npx remotion render --gl=angle --concurrency=1
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: measuring-dom-nodes
|
||||
description: Measuring DOM element dimensions in Remotion
|
||||
metadata:
|
||||
tags: measure, layout, dimensions, getBoundingClientRect, scale
|
||||
---
|
||||
|
||||
# Measuring DOM nodes in Remotion
|
||||
|
||||
Remotion applies a `scale()` transform to the video container, which affects values from `getBoundingClientRect()`. Use `useCurrentScale()` to get correct measurements.
|
||||
|
||||
## Measuring element dimensions
|
||||
|
||||
```tsx
|
||||
import { useCurrentScale } from "remotion";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scale = useCurrentScale();
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setDimensions({
|
||||
width: rect.width / scale,
|
||||
height: rect.height / scale,
|
||||
});
|
||||
}, [scale]);
|
||||
|
||||
return <div ref={ref}>Content to measure</div>;
|
||||
};
|
||||
```
|
||||
|
||||
140
.agents/skills/remotion-best-practices/rules/measuring-text.md
Normal file
140
.agents/skills/remotion-best-practices/rules/measuring-text.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: measuring-text
|
||||
description: Measuring text dimensions, fitting text to containers, and checking overflow
|
||||
metadata:
|
||||
tags: measure, text, layout, dimensions, fitText, fillTextBox
|
||||
---
|
||||
|
||||
# Measuring text in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install @remotion/layout-utils if it is not already installed:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/layout-utils
|
||||
```
|
||||
|
||||
## Measuring text dimensions
|
||||
|
||||
Use `measureText()` to calculate the width and height of text:
|
||||
|
||||
```tsx
|
||||
import { measureText } from "@remotion/layout-utils";
|
||||
|
||||
const { width, height } = measureText({
|
||||
text: "Hello World",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
});
|
||||
```
|
||||
|
||||
Results are cached - duplicate calls return the cached result.
|
||||
|
||||
## Fitting text to a width
|
||||
|
||||
Use `fitText()` to find the optimal font size for a container:
|
||||
|
||||
```tsx
|
||||
import { fitText } from "@remotion/layout-utils";
|
||||
|
||||
const { fontSize } = fitText({
|
||||
text: "Hello World",
|
||||
withinWidth: 600,
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: Math.min(fontSize, 80), // Cap at 80px
|
||||
fontFamily: "Inter",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
Hello World
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
## Checking text overflow
|
||||
|
||||
Use `fillTextBox()` to check if text exceeds a box:
|
||||
|
||||
```tsx
|
||||
import { fillTextBox } from "@remotion/layout-utils";
|
||||
|
||||
const box = fillTextBox({ maxBoxWidth: 400, maxLines: 3 });
|
||||
|
||||
const words = ["Hello", "World", "This", "is", "a", "test"];
|
||||
for (const word of words) {
|
||||
const { exceedsBox } = box.add({
|
||||
text: word + " ",
|
||||
fontFamily: "Arial",
|
||||
fontSize: 24,
|
||||
});
|
||||
if (exceedsBox) {
|
||||
// Text would overflow, handle accordingly
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
**Load fonts first:** Only call measurement functions after fonts are loaded.
|
||||
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
|
||||
const { fontFamily, waitUntilDone } = loadFont("normal", {
|
||||
weights: ["400"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
waitUntilDone().then(() => {
|
||||
// Now safe to measure
|
||||
const { width } = measureText({
|
||||
text: "Hello",
|
||||
fontFamily,
|
||||
fontSize: 32,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Use validateFontIsLoaded:** Catch font loading issues early:
|
||||
|
||||
```tsx
|
||||
measureText({
|
||||
text: "Hello",
|
||||
fontFamily: "MyCustomFont",
|
||||
fontSize: 32,
|
||||
validateFontIsLoaded: true, // Throws if font not loaded
|
||||
});
|
||||
```
|
||||
|
||||
**Match font properties:** Use the same properties for measurement and rendering:
|
||||
|
||||
```tsx
|
||||
const fontStyle = {
|
||||
fontFamily: "Inter",
|
||||
fontSize: 32,
|
||||
fontWeight: "bold" as const,
|
||||
letterSpacing: "0.5px",
|
||||
};
|
||||
|
||||
const { width } = measureText({
|
||||
text: "Hello",
|
||||
...fontStyle,
|
||||
});
|
||||
|
||||
return <div style={fontStyle}>Hello</div>;
|
||||
```
|
||||
|
||||
**Avoid padding and border:** Use `outline` instead of `border` to prevent layout differences:
|
||||
|
||||
```tsx
|
||||
<div style={{ outline: "2px solid red" }}>Text</div>
|
||||
```
|
||||
98
.agents/skills/remotion-best-practices/rules/parameters.md
Normal file
98
.agents/skills/remotion-best-practices/rules/parameters.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: parameters
|
||||
description: Make a video parametrizable by adding a Zod schema
|
||||
metadata:
|
||||
tags: parameters, zod, schema
|
||||
---
|
||||
|
||||
To make a video parametrizable, a Zod schema can be added to a composition.
|
||||
|
||||
First, `zod` must be installed - it must be exactly version `3.22.3`.
|
||||
|
||||
Search the project for lockfiles and run the correct command depending on the package manager:
|
||||
|
||||
If `package-lock.json` is found, use the following command:
|
||||
|
||||
```bash
|
||||
npm i zod@3.22.3
|
||||
```
|
||||
|
||||
If `bun.lockb` is found, use the following command:
|
||||
|
||||
```bash
|
||||
bun i zod@3.22.3
|
||||
```
|
||||
|
||||
If `yarn.lock` is found, use the following command:
|
||||
|
||||
```bash
|
||||
yarn add zod@3.22.3
|
||||
```
|
||||
|
||||
If `pnpm-lock.yaml` is found, use the following command:
|
||||
|
||||
```bash
|
||||
pnpm i zod@3.22.3
|
||||
```
|
||||
|
||||
Then, a Zod schema can be defined alongside the component:
|
||||
|
||||
```tsx title="src/MyComposition.tsx"
|
||||
import {z} from 'zod';
|
||||
|
||||
export const MyCompositionSchema = z.object({
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const MyComponent: React.FC<z.infer<typeof MyCompositionSchema>> = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
In the root file, the schema can be passed to the composition:
|
||||
|
||||
```tsx title="src/Root.tsx"
|
||||
import {Composition} from 'remotion';
|
||||
import {MycComponent, MyCompositionSchema} from './MyComposition';
|
||||
|
||||
export const RemotionRoot = () => {
|
||||
return <Composition id="MyComposition" component={MyComponent} durationInFrames={100} fps={30} width={1080} height={1080} defaultProps={{title: 'Hello World'}} schema={MyCompositionSchema} />;
|
||||
};
|
||||
```
|
||||
|
||||
Now, the user can edit the parameter visually in the sidebar.
|
||||
|
||||
All schemas that are supported by Zod are supported by Remotion.
|
||||
|
||||
Remotion requires that the top-level type is a z.object(), because the collection of props of a React component is always an object.
|
||||
|
||||
## Color picker
|
||||
|
||||
For adding a color picker, use `zColor()` from `@remotion/zod-types`.
|
||||
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/zod-types # If project uses npm
|
||||
bunx remotion add @remotion/zod-types # If project uses bun
|
||||
yarn remotion add @remotion/zod-types # If project uses yarn
|
||||
pnpm exec remotion add @remotion/zod-types # If project uses pnpm
|
||||
```
|
||||
|
||||
Then import `zColor` from `@remotion/zod-types`:
|
||||
|
||||
```tsx
|
||||
import {zColor} from '@remotion/zod-types';
|
||||
```
|
||||
|
||||
Then use it in the schema:
|
||||
|
||||
```tsx
|
||||
export const MyCompositionSchema = z.object({
|
||||
color: zColor(),
|
||||
});
|
||||
```
|
||||
118
.agents/skills/remotion-best-practices/rules/sequencing.md
Normal file
118
.agents/skills/remotion-best-practices/rules/sequencing.md
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: sequencing
|
||||
description: Sequencing patterns for Remotion - delay, trim, limit duration of items
|
||||
metadata:
|
||||
tags: sequence, series, timing, delay, trim
|
||||
---
|
||||
|
||||
Use `<Sequence>` to delay when an element appears in the timeline.
|
||||
|
||||
```tsx
|
||||
import { Sequence } from "remotion";
|
||||
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
<Sequence from={1 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence from={2 * fps} durationInFrames={2 * fps} premountFor={1 * fps}>
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
This will by default wrap the component in an absolute fill element.
|
||||
If the items should not be wrapped, use the `layout` prop:
|
||||
|
||||
```tsx
|
||||
<Sequence layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Premounting
|
||||
|
||||
This loads the component in the timeline before it is actually played.
|
||||
Always premount any `<Sequence>`!
|
||||
|
||||
```tsx
|
||||
<Sequence premountFor={1 * fps}>
|
||||
<Title />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Series
|
||||
|
||||
Use `<Series>` when elements should play one after another without overlap.
|
||||
|
||||
```tsx
|
||||
import {Series} from 'remotion';
|
||||
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={45}>
|
||||
<Intro />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={60}>
|
||||
<MainContent />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={30}>
|
||||
<Outro />
|
||||
</Series.Sequence>
|
||||
</Series>;
|
||||
```
|
||||
|
||||
Same as with `<Sequence>`, the items will be wrapped in an absolute fill element by default when using `<Series.Sequence>`, unless the `layout` prop is set to `none`.
|
||||
|
||||
### Series with overlaps
|
||||
|
||||
Use negative offset for overlapping sequences:
|
||||
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence offset={-15} durationInFrames={60}>
|
||||
{/* Starts 15 frames before SceneA ends */}
|
||||
<SceneB />
|
||||
</Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
|
||||
## Frame References Inside Sequences
|
||||
|
||||
Inside a Sequence, `useCurrentFrame()` returns the local frame (starting from 0):
|
||||
|
||||
```tsx
|
||||
<Sequence from={60} durationInFrames={30}>
|
||||
<MyComponent />
|
||||
{/* Inside MyComponent, useCurrentFrame() returns 0-29, not 60-89 */}
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Nested Sequences
|
||||
|
||||
Sequences can be nested for complex timing:
|
||||
|
||||
```tsx
|
||||
<Sequence from={0} durationInFrames={120}>
|
||||
<Background />
|
||||
<Sequence from={15} durationInFrames={90} layout="none">
|
||||
<Title />
|
||||
</Sequence>
|
||||
<Sequence from={45} durationInFrames={60} layout="none">
|
||||
<Subtitle />
|
||||
</Sequence>
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
## Nesting compositions within another
|
||||
|
||||
To add a composition within another composition, you can use the `<Sequence>` component with a `width` and `height` prop to specify the size of the composition.
|
||||
|
||||
```tsx
|
||||
<AbsoluteFill>
|
||||
<Sequence width={COMPOSITION_WIDTH} height={COMPOSITION_HEIGHT}>
|
||||
<CompositionComponent />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
```
|
||||
36
.agents/skills/remotion-best-practices/rules/subtitles.md
Normal file
36
.agents/skills/remotion-best-practices/rules/subtitles.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: subtitles
|
||||
description: subtitles and caption rules
|
||||
metadata:
|
||||
tags: subtitles, captions, remotion, json
|
||||
---
|
||||
|
||||
All captions must be processed in JSON. The captions must use the `Caption` type which is the following:
|
||||
|
||||
```ts
|
||||
import type { Caption } from "@remotion/captions";
|
||||
```
|
||||
|
||||
This is the definition:
|
||||
|
||||
```ts
|
||||
type Caption = {
|
||||
text: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
timestampMs: number | null;
|
||||
confidence: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
## Generating captions
|
||||
|
||||
To transcribe video and audio files to generate captions, load the [./transcribe-captions.md](./transcribe-captions.md) file for more instructions.
|
||||
|
||||
## Displaying captions
|
||||
|
||||
To display captions in your video, load the [./display-captions.md](./display-captions.md) file for more instructions.
|
||||
|
||||
## Importing captions
|
||||
|
||||
To import captions from a .srt file, load the [./import-srt-captions.md](./import-srt-captions.md) file for more instructions.
|
||||
11
.agents/skills/remotion-best-practices/rules/tailwind.md
Normal file
11
.agents/skills/remotion-best-practices/rules/tailwind.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: tailwind
|
||||
description: Using TailwindCSS in Remotion.
|
||||
metadata:
|
||||
---
|
||||
|
||||
You can and should use TailwindCSS in Remotion, if TailwindCSS is installed in the project.
|
||||
|
||||
Don't use `transition-*` or `animate-*` classes - always animate using the `useCurrentFrame()` hook.
|
||||
|
||||
Tailwind must be installed and enabled first in a Remotion project - fetch https://www.remotion.dev/docs/tailwind using WebFetch for instructions.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: text-animations
|
||||
description: Typography and text animation patterns for Remotion.
|
||||
metadata:
|
||||
tags: typography, text, typewriter, highlighter ken
|
||||
---
|
||||
|
||||
## Text animations
|
||||
|
||||
Based on `useCurrentFrame()`, reduce the string character by character to create a typewriter effect.
|
||||
|
||||
## Typewriter Effect
|
||||
|
||||
See [Typewriter](assets/text-animations-typewriter.tsx) for an advanced example with a blinking cursor and a pause after the first sentence.
|
||||
|
||||
Always use string slicing for typewriter effects. Never use per-character opacity.
|
||||
|
||||
## Word Highlighting
|
||||
|
||||
See [Word Highlight](assets/text-animations-word-highlight.tsx) for an example for how a word highlight is animated, like with a highlighter pen.
|
||||
179
.agents/skills/remotion-best-practices/rules/timing.md
Normal file
179
.agents/skills/remotion-best-practices/rules/timing.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
name: timing
|
||||
description: Interpolation curves in Remotion - linear, easing, spring animations
|
||||
metadata:
|
||||
tags: spring, bounce, easing, interpolation
|
||||
---
|
||||
|
||||
A simple linear interpolation is done using the `interpolate` function.
|
||||
|
||||
```ts title="Going from 0 to 1 over 100 frames"
|
||||
import {interpolate} from 'remotion';
|
||||
|
||||
const opacity = interpolate(frame, [0, 100], [0, 1]);
|
||||
```
|
||||
|
||||
By default, the values are not clamped, so the value can go outside the range [0, 1].
|
||||
Here is how they can be clamped:
|
||||
|
||||
```ts title="Going from 0 to 1 over 100 frames with extrapolation"
|
||||
const opacity = interpolate(frame, [0, 100], [0, 1], {
|
||||
extrapolateRight: 'clamp',
|
||||
extrapolateLeft: 'clamp',
|
||||
});
|
||||
```
|
||||
|
||||
## Spring animations
|
||||
|
||||
Spring animations have a more natural motion.
|
||||
They go from 0 to 1 over time.
|
||||
|
||||
```ts title="Spring animation from 0 to 1 over 100 frames"
|
||||
import {spring, useCurrentFrame, useVideoConfig} from 'remotion';
|
||||
|
||||
const frame = useCurrentFrame();
|
||||
const {fps} = useVideoConfig();
|
||||
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
});
|
||||
```
|
||||
|
||||
### Physical properties
|
||||
|
||||
The default configuration is: `mass: 1, damping: 10, stiffness: 100`.
|
||||
This leads to the animation having a bit of bounce before it settles.
|
||||
|
||||
The config can be overwritten like this:
|
||||
|
||||
```ts
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: {damping: 200},
|
||||
});
|
||||
```
|
||||
|
||||
The recommended configuration for a natural motion without a bounce is: `{ damping: 200 }`.
|
||||
|
||||
Here are some common configurations:
|
||||
|
||||
```tsx
|
||||
const smooth = {damping: 200}; // Smooth, no bounce (subtle reveals)
|
||||
const snappy = {damping: 20, stiffness: 200}; // Snappy, minimal bounce (UI elements)
|
||||
const bouncy = {damping: 8}; // Bouncy entrance (playful animations)
|
||||
const heavy = {damping: 15, stiffness: 80, mass: 2}; // Heavy, slow, small bounce
|
||||
```
|
||||
|
||||
### Delay
|
||||
|
||||
The animation starts immediately by default.
|
||||
Use the `delay` parameter to delay the animation by a number of frames.
|
||||
|
||||
```tsx
|
||||
const entrance = spring({
|
||||
frame: frame - ENTRANCE_DELAY,
|
||||
fps,
|
||||
delay: 20,
|
||||
});
|
||||
```
|
||||
|
||||
### Duration
|
||||
|
||||
A `spring()` has a natural duration based on the physical properties.
|
||||
To stretch the animation to a specific duration, use the `durationInFrames` parameter.
|
||||
|
||||
```tsx
|
||||
const spring = spring({
|
||||
frame,
|
||||
fps,
|
||||
durationInFrames: 40,
|
||||
});
|
||||
```
|
||||
|
||||
### Combining spring() with interpolate()
|
||||
|
||||
Map spring output (0-1) to custom ranges:
|
||||
|
||||
```tsx
|
||||
const springProgress = spring({
|
||||
frame,
|
||||
fps,
|
||||
});
|
||||
|
||||
// Map to rotation
|
||||
const rotation = interpolate(springProgress, [0, 1], [0, 360]);
|
||||
|
||||
<div style={{rotate: rotation + 'deg'}} />;
|
||||
```
|
||||
|
||||
### Adding springs
|
||||
|
||||
Springs return just numbers, so math can be performed:
|
||||
|
||||
```tsx
|
||||
const frame = useCurrentFrame();
|
||||
const {fps, durationInFrames} = useVideoConfig();
|
||||
|
||||
const inAnimation = spring({
|
||||
frame,
|
||||
fps,
|
||||
});
|
||||
const outAnimation = spring({
|
||||
frame,
|
||||
fps,
|
||||
durationInFrames: 1 * fps,
|
||||
delay: durationInFrames - 1 * fps,
|
||||
});
|
||||
|
||||
const scale = inAnimation - outAnimation;
|
||||
```
|
||||
|
||||
## Easing
|
||||
|
||||
Easing can be added to the `interpolate` function:
|
||||
|
||||
```ts
|
||||
import {interpolate, Easing} from 'remotion';
|
||||
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
```
|
||||
|
||||
The default easing is `Easing.linear`.
|
||||
There are various other convexities:
|
||||
|
||||
- `Easing.in` for starting slow and accelerating
|
||||
- `Easing.out` for starting fast and slowing down
|
||||
- `Easing.inOut`
|
||||
|
||||
and curves (sorted from most linear to most curved):
|
||||
|
||||
- `Easing.quad`
|
||||
- `Easing.sin`
|
||||
- `Easing.exp`
|
||||
- `Easing.circle`
|
||||
|
||||
Convexities and curves need be combined for an easing function:
|
||||
|
||||
```ts
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
```
|
||||
|
||||
Cubic bezier curves are also supported:
|
||||
|
||||
```ts
|
||||
const value1 = interpolate(frame, [0, 100], [0, 1], {
|
||||
easing: Easing.bezier(0.8, 0.22, 0.96, 0.65),
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: transcribe-captions
|
||||
description: Transcribing audio to generate captions in Remotion
|
||||
metadata:
|
||||
tags: captions, transcribe, whisper, audio, speech-to-text
|
||||
---
|
||||
|
||||
# Transcribing audio
|
||||
|
||||
To transcribe audio to generate captions in Remotion, you can use the [`transcribe()`](https://www.remotion.dev/docs/install-whisper-cpp/transcribe) function from the [`@remotion/install-whisper-cpp`](https://www.remotion.dev/docs/install-whisper-cpp) package.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/install-whisper-cpp package needs to be installed.
|
||||
If it is not installed, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/install-whisper-cpp
|
||||
```
|
||||
|
||||
## Transcribing
|
||||
|
||||
Make a Node.js script to download Whisper.cpp and a model, and transcribe the audio.
|
||||
|
||||
```ts
|
||||
import path from "path";
|
||||
import {
|
||||
downloadWhisperModel,
|
||||
installWhisperCpp,
|
||||
transcribe,
|
||||
toCaptions,
|
||||
} from "@remotion/install-whisper-cpp";
|
||||
import fs from "fs";
|
||||
|
||||
const to = path.join(process.cwd(), "whisper.cpp");
|
||||
|
||||
await installWhisperCpp({
|
||||
to,
|
||||
version: "1.5.5",
|
||||
});
|
||||
|
||||
await downloadWhisperModel({
|
||||
model: "medium.en",
|
||||
folder: to,
|
||||
});
|
||||
|
||||
// Convert the audio to a 16KHz wav file first if needed:
|
||||
// import {execSync} from 'child_process';
|
||||
// execSync('ffmpeg -i /path/to/audio.mp4 -ar 16000 /path/to/audio.wav -y');
|
||||
|
||||
const whisperCppOutput = await transcribe({
|
||||
model: "medium.en",
|
||||
whisperPath: to,
|
||||
whisperCppVersion: "1.5.5",
|
||||
inputPath: "/path/to/audio123.wav",
|
||||
tokenLevelTimestamps: true,
|
||||
});
|
||||
|
||||
// Optional: Apply our recommended postprocessing
|
||||
const { captions } = toCaptions({
|
||||
whisperCppOutput,
|
||||
});
|
||||
|
||||
// Write it to the public/ folder so it can be fetched from Remotion
|
||||
fs.writeFileSync("captions123.json", JSON.stringify(captions, null, 2));
|
||||
```
|
||||
|
||||
Transcribe each clip individually and create multiple JSON files.
|
||||
|
||||
See [Displaying captions](display-captions.md) for how to display the captions in Remotion.
|
||||
197
.agents/skills/remotion-best-practices/rules/transitions.md
Normal file
197
.agents/skills/remotion-best-practices/rules/transitions.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: transitions
|
||||
description: Scene transitions and overlays for Remotion using TransitionSeries.
|
||||
metadata:
|
||||
tags: transitions, overlays, fade, slide, wipe, scenes
|
||||
---
|
||||
|
||||
## TransitionSeries
|
||||
|
||||
`<TransitionSeries>` arranges scenes and supports two ways to enhance the cut point between them:
|
||||
|
||||
- **Transitions** (`<TransitionSeries.Transition>`) — crossfade, slide, wipe, etc. between two scenes. Shortens the timeline because both scenes play simultaneously during the transition.
|
||||
- **Overlays** (`<TransitionSeries.Overlay>`) — render an effect (e.g. a light leak) on top of the cut point without shortening the timeline.
|
||||
|
||||
Children are absolutely positioned.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/transitions
|
||||
```
|
||||
|
||||
## Transition example
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 15 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Overlay example
|
||||
|
||||
Any React component can be used as an overlay. For a ready-made effect, see the **light-leaks** rule.
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries } from "@remotion/transitions";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={20}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Mixing transitions and overlays
|
||||
|
||||
Transitions and overlays can coexist in the same `<TransitionSeries>`, but an overlay cannot be adjacent to a transition or another overlay.
|
||||
|
||||
```tsx
|
||||
import { TransitionSeries, linearTiming } from "@remotion/transitions";
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { LightLeak } from "@remotion/light-leaks";
|
||||
|
||||
<TransitionSeries>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneA />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Overlay durationInFrames={30}>
|
||||
<LightLeak />
|
||||
</TransitionSeries.Overlay>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneB />
|
||||
</TransitionSeries.Sequence>
|
||||
<TransitionSeries.Transition
|
||||
presentation={fade()}
|
||||
timing={linearTiming({ durationInFrames: 15 })}
|
||||
/>
|
||||
<TransitionSeries.Sequence durationInFrames={60}>
|
||||
<SceneC />
|
||||
</TransitionSeries.Sequence>
|
||||
</TransitionSeries>;
|
||||
```
|
||||
|
||||
## Transition props
|
||||
|
||||
`<TransitionSeries.Transition>` requires:
|
||||
|
||||
- `presentation` — the visual effect (e.g. `fade()`, `slide()`, `wipe()`).
|
||||
- `timing` — controls speed and easing (e.g. `linearTiming()`, `springTiming()`).
|
||||
|
||||
## Overlay props
|
||||
|
||||
`<TransitionSeries.Overlay>` accepts:
|
||||
|
||||
- `durationInFrames` — how long the overlay is visible (positive integer).
|
||||
- `offset?` — shifts the overlay relative to the cut point center. Positive = later, negative = earlier. Default: `0`.
|
||||
|
||||
## Available transition types
|
||||
|
||||
Import transitions from their respective modules:
|
||||
|
||||
```tsx
|
||||
import { fade } from "@remotion/transitions/fade";
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
import { wipe } from "@remotion/transitions/wipe";
|
||||
import { flip } from "@remotion/transitions/flip";
|
||||
import { clockWipe } from "@remotion/transitions/clock-wipe";
|
||||
```
|
||||
|
||||
## Slide transition with direction
|
||||
|
||||
```tsx
|
||||
import { slide } from "@remotion/transitions/slide";
|
||||
|
||||
<TransitionSeries.Transition
|
||||
presentation={slide({ direction: "from-left" })}
|
||||
timing={linearTiming({ durationInFrames: 20 })}
|
||||
/>;
|
||||
```
|
||||
|
||||
Directions: `"from-left"`, `"from-right"`, `"from-top"`, `"from-bottom"`
|
||||
|
||||
## Timing options
|
||||
|
||||
```tsx
|
||||
import { linearTiming, springTiming } from "@remotion/transitions";
|
||||
|
||||
// Linear timing - constant speed
|
||||
linearTiming({ durationInFrames: 20 });
|
||||
|
||||
// Spring timing - organic motion
|
||||
springTiming({ config: { damping: 200 }, durationInFrames: 25 });
|
||||
```
|
||||
|
||||
## Duration calculation
|
||||
|
||||
Transitions overlap adjacent scenes, so the total composition length is **shorter** than the sum of all sequence durations. Overlays do **not** affect the total duration.
|
||||
|
||||
For example, with two 60-frame sequences and a 15-frame transition:
|
||||
|
||||
- Without transitions: `60 + 60 = 120` frames
|
||||
- With transition: `60 + 60 - 15 = 105` frames
|
||||
|
||||
Adding an overlay between two other sequences does not change the total.
|
||||
|
||||
### Getting the duration of a transition
|
||||
|
||||
Use the `getDurationInFrames()` method on the timing object:
|
||||
|
||||
```tsx
|
||||
import { linearTiming, springTiming } from "@remotion/transitions";
|
||||
|
||||
const linearDuration = linearTiming({
|
||||
durationInFrames: 20,
|
||||
}).getDurationInFrames({ fps: 30 });
|
||||
// Returns 20
|
||||
|
||||
const springDuration = springTiming({
|
||||
config: { damping: 200 },
|
||||
}).getDurationInFrames({ fps: 30 });
|
||||
// Returns calculated duration based on spring physics
|
||||
```
|
||||
|
||||
For `springTiming` without an explicit `durationInFrames`, the duration depends on `fps` because it calculates when the spring animation settles.
|
||||
|
||||
### Calculating total composition duration
|
||||
|
||||
```tsx
|
||||
import { linearTiming } from "@remotion/transitions";
|
||||
|
||||
const scene1Duration = 60;
|
||||
const scene2Duration = 60;
|
||||
const scene3Duration = 60;
|
||||
|
||||
const timing1 = linearTiming({ durationInFrames: 15 });
|
||||
const timing2 = linearTiming({ durationInFrames: 20 });
|
||||
|
||||
const transition1Duration = timing1.getDurationInFrames({ fps: 30 });
|
||||
const transition2Duration = timing2.getDurationInFrames({ fps: 30 });
|
||||
|
||||
const totalDuration =
|
||||
scene1Duration +
|
||||
scene2Duration +
|
||||
scene3Duration -
|
||||
transition1Duration -
|
||||
transition2Duration;
|
||||
// 60 + 60 + 60 - 15 - 20 = 145 frames
|
||||
```
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: transparent-videos
|
||||
description: Rendering transparent videos in Remotion
|
||||
metadata:
|
||||
tags: transparent, alpha, codec, vp9, prores, webm
|
||||
---
|
||||
|
||||
# Rendering Transparent Videos
|
||||
|
||||
Remotion can render transparent videos in two ways: as a ProRes video or as a WebM video.
|
||||
|
||||
## Transparent ProRes
|
||||
|
||||
Ideal for when importing into video editing software.
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
npx remotion render --image-format=png --pixel-format=yuva444p10le --codec=prores --prores-profile=4444 MyComp out.mov
|
||||
```
|
||||
|
||||
**Default in Studio** (restart Studio after changing):
|
||||
|
||||
```ts
|
||||
// remotion.config.ts
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setPixelFormat("yuva444p10le");
|
||||
Config.setCodec("prores");
|
||||
Config.setProResProfile("4444");
|
||||
```
|
||||
|
||||
**Setting it as the default export settings for a composition** (using `calculateMetadata`):
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultCodec: "prores",
|
||||
defaultVideoImageFormat: "png",
|
||||
defaultPixelFormat: "yuva444p10le",
|
||||
defaultProResProfile: "4444",
|
||||
};
|
||||
};
|
||||
|
||||
<Composition
|
||||
id="my-video"
|
||||
component={MyVideo}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>;
|
||||
```
|
||||
|
||||
## Transparent WebM (VP9)
|
||||
|
||||
Ideal for when playing in a browser.
|
||||
|
||||
**CLI:**
|
||||
|
||||
```bash
|
||||
npx remotion render --image-format=png --pixel-format=yuva420p --codec=vp9 MyComp out.webm
|
||||
```
|
||||
|
||||
**Default in Studio** (restart Studio after changing):
|
||||
|
||||
```ts
|
||||
// remotion.config.ts
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("png");
|
||||
Config.setPixelFormat("yuva420p");
|
||||
Config.setCodec("vp9");
|
||||
```
|
||||
|
||||
**Setting it as the default export settings for a composition** (using `calculateMetadata`):
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
return {
|
||||
defaultCodec: "vp8",
|
||||
defaultVideoImageFormat: "png",
|
||||
defaultPixelFormat: "yuva420p",
|
||||
};
|
||||
};
|
||||
|
||||
<Composition
|
||||
id="my-video"
|
||||
component={MyVideo}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
calculateMetadata={calculateMetadata}
|
||||
/>;
|
||||
```
|
||||
53
.agents/skills/remotion-best-practices/rules/trimming.md
Normal file
53
.agents/skills/remotion-best-practices/rules/trimming.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: trimming
|
||||
description: Trimming patterns for Remotion - cut the beginning or end of animations
|
||||
metadata:
|
||||
tags: sequence, trim, clip, cut, offset
|
||||
---
|
||||
|
||||
Use `<Sequence>` with a negative `from` value to trim the start of an animation.
|
||||
|
||||
## Trim the Beginning
|
||||
|
||||
A negative `from` value shifts time backwards, making the animation start partway through:
|
||||
|
||||
```tsx
|
||||
import { Sequence, useVideoConfig } from "remotion";
|
||||
|
||||
const fps = useVideoConfig();
|
||||
|
||||
<Sequence from={-0.5 * fps}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The animation appears 15 frames into its progress - the first 15 frames are trimmed off.
|
||||
Inside `<MyAnimation>`, `useCurrentFrame()` starts at 15 instead of 0.
|
||||
|
||||
## Trim the End
|
||||
|
||||
Use `durationInFrames` to unmount content after a specified duration:
|
||||
|
||||
```tsx
|
||||
|
||||
<Sequence durationInFrames={1.5 * fps}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The animation plays for 45 frames, then the component unmounts.
|
||||
|
||||
## Trim and Delay
|
||||
|
||||
Nest sequences to both trim the beginning and delay when it appears:
|
||||
|
||||
```tsx
|
||||
<Sequence from={30}>
|
||||
<Sequence from={-15}>
|
||||
<MyAnimation />
|
||||
</Sequence>
|
||||
</Sequence>
|
||||
```
|
||||
|
||||
The inner sequence trims 15 frames from the start, and the outer sequence delays the result by 30 frames.
|
||||
|
||||
171
.agents/skills/remotion-best-practices/rules/videos.md
Normal file
171
.agents/skills/remotion-best-practices/rules/videos.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: videos
|
||||
description: Embedding videos in Remotion - trimming, volume, speed, looping, pitch
|
||||
metadata:
|
||||
tags: video, media, trim, volume, speed, loop, pitch
|
||||
---
|
||||
|
||||
# Using videos in Remotion
|
||||
|
||||
## Prerequisites
|
||||
|
||||
First, the @remotion/media package needs to be installed.
|
||||
If it is not, use the following command:
|
||||
|
||||
```bash
|
||||
npx remotion add @remotion/media # If project uses npm
|
||||
bunx remotion add @remotion/media # If project uses bun
|
||||
yarn remotion add @remotion/media # If project uses yarn
|
||||
pnpm exec remotion add @remotion/media # If project uses pnpm
|
||||
```
|
||||
|
||||
Use `<Video>` from `@remotion/media` to embed videos into your composition.
|
||||
|
||||
```tsx
|
||||
import { Video } from "@remotion/media";
|
||||
import { staticFile } from "remotion";
|
||||
|
||||
export const MyComposition = () => {
|
||||
return <Video src={staticFile("video.mp4")} />;
|
||||
};
|
||||
```
|
||||
|
||||
Remote URLs are also supported:
|
||||
|
||||
```tsx
|
||||
<Video src="https://remotion.media/video.mp4" />
|
||||
```
|
||||
|
||||
## Trimming
|
||||
|
||||
Use `trimBefore` and `trimAfter` to remove portions of the video. Values are in seconds.
|
||||
|
||||
```tsx
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
trimBefore={2 * fps} // Skip the first 2 seconds
|
||||
trimAfter={10 * fps} // End at the 10 second mark
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
## Delaying
|
||||
|
||||
Wrap the video in a `<Sequence>` to delay when it appears:
|
||||
|
||||
```tsx
|
||||
import { Sequence, staticFile } from "remotion";
|
||||
import { Video } from "@remotion/media";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Sequence from={1 * fps}>
|
||||
<Video src={staticFile("video.mp4")} />
|
||||
</Sequence>
|
||||
);
|
||||
```
|
||||
|
||||
The video will appear after 1 second.
|
||||
|
||||
## Sizing and Position
|
||||
|
||||
Use the `style` prop to control size and position:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
style={{
|
||||
width: 500,
|
||||
height: 300,
|
||||
position: "absolute",
|
||||
top: 100,
|
||||
left: 50,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Volume
|
||||
|
||||
Set a static volume (0 to 1):
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} volume={0.5} />
|
||||
```
|
||||
|
||||
Or use a callback for dynamic volume based on the current frame:
|
||||
|
||||
```tsx
|
||||
import { interpolate } from "remotion";
|
||||
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
volume={(f) =>
|
||||
interpolate(f, [0, 1 * fps], [0, 1], { extrapolateRight: "clamp" })
|
||||
}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
Use `muted` to silence the video entirely:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} muted />
|
||||
```
|
||||
|
||||
## Speed
|
||||
|
||||
Use `playbackRate` to change the playback speed:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} playbackRate={2} /> {/* 2x speed */}
|
||||
<Video src={staticFile("video.mp4")} playbackRate={0.5} /> {/* Half speed */}
|
||||
```
|
||||
|
||||
Reverse playback is not supported.
|
||||
|
||||
## Looping
|
||||
|
||||
Use `loop` to loop the video indefinitely:
|
||||
|
||||
```tsx
|
||||
<Video src={staticFile("video.mp4")} loop />
|
||||
```
|
||||
|
||||
Use `loopVolumeCurveBehavior` to control how the frame count behaves when looping:
|
||||
|
||||
- `"repeat"`: Frame count resets to 0 each loop (for `volume` callback)
|
||||
- `"extend"`: Frame count continues incrementing
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
loop
|
||||
loopVolumeCurveBehavior="extend"
|
||||
volume={(f) => interpolate(f, [0, 300], [1, 0])} // Fade out over multiple loops
|
||||
/>
|
||||
```
|
||||
|
||||
## Pitch
|
||||
|
||||
Use `toneFrequency` to adjust the pitch without affecting speed. Values range from 0.01 to 2:
|
||||
|
||||
```tsx
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
toneFrequency={1.5} // Higher pitch
|
||||
/>
|
||||
<Video
|
||||
src={staticFile("video.mp4")}
|
||||
toneFrequency={0.8} // Lower pitch
|
||||
/>
|
||||
```
|
||||
|
||||
Pitch shifting only works during server-side rendering, not in the Remotion Studio preview or in the `<Player />`.
|
||||
103
.agents/skills/remotion-best-practices/rules/voiceover.md
Normal file
103
.agents/skills/remotion-best-practices/rules/voiceover.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: voiceover
|
||||
description: Adding AI-generated voiceover to Remotion compositions using ElevenLabs TTS
|
||||
metadata:
|
||||
tags: voiceover, audio, elevenlabs, tts, speech, calculateMetadata, dynamic duration
|
||||
---
|
||||
|
||||
# Adding AI voiceover to a Remotion composition
|
||||
|
||||
Use ElevenLabs TTS to generate speech audio per scene, then use [`calculateMetadata`](./calculate-metadata) to dynamically size the composition to match the audio.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
An **ElevenLabs API key** is required. Store it in a `.env` file at the project root:
|
||||
|
||||
```
|
||||
ELEVENLABS_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
**MUST** ask the user for their ElevenLabs API key if no `.env` file exists or `ELEVENLABS_API_KEY` is not set. **MUST NOT** fall back to other TTS tools.
|
||||
|
||||
When running the generation script, use the `--env-file` flag to load the `.env` file:
|
||||
|
||||
```bash
|
||||
node --env-file=.env --strip-types generate-voiceover.ts
|
||||
```
|
||||
|
||||
## Generating audio with ElevenLabs
|
||||
|
||||
Create a script that reads the config, calls the ElevenLabs API for each scene, and writes MP3 files to the `public/` directory so Remotion can access them via `staticFile()`.
|
||||
|
||||
The core API call for a single scene:
|
||||
|
||||
```ts title="generate-voiceover.ts"
|
||||
const response = await fetch(
|
||||
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"xi-api-key": process.env.ELEVENLABS_API_KEY!,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "audio/mpeg",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: "Welcome to the show.",
|
||||
model_id: "eleven_multilingual_v2",
|
||||
voice_settings: {
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
style: 0.3,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const audioBuffer = Buffer.from(await response.arrayBuffer());
|
||||
writeFileSync(`public/voiceover/${compositionId}/${scene.id}.mp3`, audioBuffer);
|
||||
```
|
||||
|
||||
## Dynamic composition duration with calculateMetadata
|
||||
|
||||
Use [`calculateMetadata`](./calculate-metadata.md) to measure the [audio durations](./get-audio-duration.md) and set the composition length accordingly.
|
||||
|
||||
```tsx
|
||||
import { CalculateMetadataFunction, staticFile } from "remotion";
|
||||
import { getAudioDuration } from "./get-audio-duration";
|
||||
|
||||
const FPS = 30;
|
||||
|
||||
const SCENE_AUDIO_FILES = [
|
||||
"voiceover/my-comp/scene-01-intro.mp3",
|
||||
"voiceover/my-comp/scene-02-main.mp3",
|
||||
"voiceover/my-comp/scene-03-outro.mp3",
|
||||
];
|
||||
|
||||
export const calculateMetadata: CalculateMetadataFunction<Props> = async ({
|
||||
props,
|
||||
}) => {
|
||||
const durations = await Promise.all(
|
||||
SCENE_AUDIO_FILES.map((file) => getAudioDuration(staticFile(file))),
|
||||
);
|
||||
|
||||
const sceneDurations = durations.map((durationInSeconds) => {
|
||||
return durationInSeconds * FPS;
|
||||
});
|
||||
|
||||
return {
|
||||
durationInFrames: Math.ceil(sceneDurations.reduce((sum, d) => sum + d, 0)),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The computed `sceneDurations` are passed into the component via a `voiceover` prop so the component knows how long each scene should be.
|
||||
|
||||
If the composition uses [`<TransitionSeries>`](./transitions.md), subtract the overlap from total duration: [./transitions.md#calculating-total-composition-duration](./transitions.md#calculating-total-composition-duration)
|
||||
|
||||
## Rendering audio in the component
|
||||
|
||||
See [audio.md](./audio.md) for more information on how to render audio in the component.
|
||||
|
||||
## Delaying audio start
|
||||
|
||||
See [audio.md#delaying](./audio.md#delaying) for more information on how to delay the audio start.
|
||||
1
.claude/skills/remotion-best-practices
Symbolic link
1
.claude/skills/remotion-best-practices
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/remotion-best-practices
|
||||
1262
apps/web/src/remotion/EcommerceDemo.tsx
Normal file
1262
apps/web/src/remotion/EcommerceDemo.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,18 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from "./routes/__root"
|
||||
import { Route as TermsRouteImport } from "./routes/terms"
|
||||
import { Route as PrivacyRouteImport } from "./routes/privacy"
|
||||
import { Route as PricingRouteImport } from "./routes/pricing"
|
||||
import { Route as KvkkRouteImport } from "./routes/kvkk"
|
||||
import { Route as DemoRouteImport } from "./routes/demo"
|
||||
import { Route as DashboardRouteImport } from "./routes/dashboard"
|
||||
import { Route as ContactRouteImport } from "./routes/contact"
|
||||
import { Route as BlogRouteImport } from "./routes/blog"
|
||||
import { Route as AboutRouteImport } from "./routes/about"
|
||||
import { Route as AuthRouteImport } from "./routes/_auth"
|
||||
import { Route as IndexRouteImport } from "./routes/index"
|
||||
import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index"
|
||||
import { Route as DashboardSettingsRouteImport } from "./routes/dashboard/settings"
|
||||
import { Route as DashboardSearchRouteImport } from "./routes/dashboard/search"
|
||||
import { Route as DashboardHistoryRouteImport } from "./routes/dashboard/history"
|
||||
@@ -32,11 +39,26 @@ import { Route as DashboardAdminAnalyticsRouteImport } from "./routes/dashboard/
|
||||
import { Route as DashboardVehiclesIdIndexRouteImport } from "./routes/dashboard/vehicles_/$id/index"
|
||||
import { Route as DashboardVehiclesIdCategoriesCategoryIdRouteImport } from "./routes/dashboard/vehicles_/$id/categories_/$categoryId"
|
||||
|
||||
const TermsRoute = TermsRouteImport.update({
|
||||
id: "/terms",
|
||||
path: "/terms",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PrivacyRoute = PrivacyRouteImport.update({
|
||||
id: "/privacy",
|
||||
path: "/privacy",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PricingRoute = PricingRouteImport.update({
|
||||
id: "/pricing",
|
||||
path: "/pricing",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const KvkkRoute = KvkkRouteImport.update({
|
||||
id: "/kvkk",
|
||||
path: "/kvkk",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DemoRoute = DemoRouteImport.update({
|
||||
id: "/demo",
|
||||
path: "/demo",
|
||||
@@ -47,6 +69,21 @@ const DashboardRoute = DashboardRouteImport.update({
|
||||
path: "/dashboard",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ContactRoute = ContactRouteImport.update({
|
||||
id: "/contact",
|
||||
path: "/contact",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlogRoute = BlogRouteImport.update({
|
||||
id: "/blog",
|
||||
path: "/blog",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: "/about",
|
||||
path: "/about",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthRoute = AuthRouteImport.update({
|
||||
id: "/_auth",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
@@ -56,6 +93,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: "/",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
getParentRoute: () => DashboardRoute,
|
||||
} as any)
|
||||
const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
|
||||
id: "/settings",
|
||||
path: "/settings",
|
||||
@@ -148,9 +190,15 @@ const DashboardVehiclesIdCategoriesCategoryIdRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof IndexRoute
|
||||
"/about": typeof AboutRoute
|
||||
"/blog": typeof BlogRoute
|
||||
"/contact": typeof ContactRoute
|
||||
"/dashboard": typeof DashboardRouteWithChildren
|
||||
"/demo": typeof DemoRoute
|
||||
"/kvkk": typeof KvkkRoute
|
||||
"/pricing": typeof PricingRoute
|
||||
"/privacy": typeof PrivacyRoute
|
||||
"/terms": typeof TermsRoute
|
||||
"/forgot-password": typeof AuthForgotPasswordRoute
|
||||
"/login": typeof AuthLoginRoute
|
||||
"/register": typeof AuthRegisterRoute
|
||||
@@ -159,6 +207,7 @@ export interface FileRoutesByFullPath {
|
||||
"/dashboard/history": typeof DashboardHistoryRoute
|
||||
"/dashboard/search": typeof DashboardSearchRoute
|
||||
"/dashboard/settings": typeof DashboardSettingsRoute
|
||||
"/dashboard/": typeof DashboardIndexRoute
|
||||
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
|
||||
"/dashboard/admin/payments": typeof DashboardAdminPaymentsRoute
|
||||
"/dashboard/admin/referrals": typeof DashboardAdminReferralsRoute
|
||||
@@ -171,9 +220,14 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof IndexRoute
|
||||
"/dashboard": typeof DashboardRouteWithChildren
|
||||
"/about": typeof AboutRoute
|
||||
"/blog": typeof BlogRoute
|
||||
"/contact": typeof ContactRoute
|
||||
"/demo": typeof DemoRoute
|
||||
"/kvkk": typeof KvkkRoute
|
||||
"/pricing": typeof PricingRoute
|
||||
"/privacy": typeof PrivacyRoute
|
||||
"/terms": typeof TermsRoute
|
||||
"/forgot-password": typeof AuthForgotPasswordRoute
|
||||
"/login": typeof AuthLoginRoute
|
||||
"/register": typeof AuthRegisterRoute
|
||||
@@ -182,6 +236,7 @@ export interface FileRoutesByTo {
|
||||
"/dashboard/history": typeof DashboardHistoryRoute
|
||||
"/dashboard/search": typeof DashboardSearchRoute
|
||||
"/dashboard/settings": typeof DashboardSettingsRoute
|
||||
"/dashboard": typeof DashboardIndexRoute
|
||||
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
|
||||
"/dashboard/admin/payments": typeof DashboardAdminPaymentsRoute
|
||||
"/dashboard/admin/referrals": typeof DashboardAdminReferralsRoute
|
||||
@@ -196,9 +251,15 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
"/": typeof IndexRoute
|
||||
"/_auth": typeof AuthRouteWithChildren
|
||||
"/about": typeof AboutRoute
|
||||
"/blog": typeof BlogRoute
|
||||
"/contact": typeof ContactRoute
|
||||
"/dashboard": typeof DashboardRouteWithChildren
|
||||
"/demo": typeof DemoRoute
|
||||
"/kvkk": typeof KvkkRoute
|
||||
"/pricing": typeof PricingRoute
|
||||
"/privacy": typeof PrivacyRoute
|
||||
"/terms": typeof TermsRoute
|
||||
"/_auth/forgot-password": typeof AuthForgotPasswordRoute
|
||||
"/_auth/login": typeof AuthLoginRoute
|
||||
"/_auth/register": typeof AuthRegisterRoute
|
||||
@@ -207,6 +268,7 @@ export interface FileRoutesById {
|
||||
"/dashboard/history": typeof DashboardHistoryRoute
|
||||
"/dashboard/search": typeof DashboardSearchRoute
|
||||
"/dashboard/settings": typeof DashboardSettingsRoute
|
||||
"/dashboard/": typeof DashboardIndexRoute
|
||||
"/dashboard/admin/analytics": typeof DashboardAdminAnalyticsRoute
|
||||
"/dashboard/admin/payments": typeof DashboardAdminPaymentsRoute
|
||||
"/dashboard/admin/referrals": typeof DashboardAdminReferralsRoute
|
||||
@@ -221,9 +283,15 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| "/"
|
||||
| "/about"
|
||||
| "/blog"
|
||||
| "/contact"
|
||||
| "/dashboard"
|
||||
| "/demo"
|
||||
| "/kvkk"
|
||||
| "/pricing"
|
||||
| "/privacy"
|
||||
| "/terms"
|
||||
| "/forgot-password"
|
||||
| "/login"
|
||||
| "/register"
|
||||
@@ -232,6 +300,7 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/history"
|
||||
| "/dashboard/search"
|
||||
| "/dashboard/settings"
|
||||
| "/dashboard/"
|
||||
| "/dashboard/admin/analytics"
|
||||
| "/dashboard/admin/payments"
|
||||
| "/dashboard/admin/referrals"
|
||||
@@ -244,9 +313,14 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| "/"
|
||||
| "/dashboard"
|
||||
| "/about"
|
||||
| "/blog"
|
||||
| "/contact"
|
||||
| "/demo"
|
||||
| "/kvkk"
|
||||
| "/pricing"
|
||||
| "/privacy"
|
||||
| "/terms"
|
||||
| "/forgot-password"
|
||||
| "/login"
|
||||
| "/register"
|
||||
@@ -255,6 +329,7 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/history"
|
||||
| "/dashboard/search"
|
||||
| "/dashboard/settings"
|
||||
| "/dashboard"
|
||||
| "/dashboard/admin/analytics"
|
||||
| "/dashboard/admin/payments"
|
||||
| "/dashboard/admin/referrals"
|
||||
@@ -268,9 +343,15 @@ export interface FileRouteTypes {
|
||||
| "__root__"
|
||||
| "/"
|
||||
| "/_auth"
|
||||
| "/about"
|
||||
| "/blog"
|
||||
| "/contact"
|
||||
| "/dashboard"
|
||||
| "/demo"
|
||||
| "/kvkk"
|
||||
| "/pricing"
|
||||
| "/privacy"
|
||||
| "/terms"
|
||||
| "/_auth/forgot-password"
|
||||
| "/_auth/login"
|
||||
| "/_auth/register"
|
||||
@@ -279,6 +360,7 @@ export interface FileRouteTypes {
|
||||
| "/dashboard/history"
|
||||
| "/dashboard/search"
|
||||
| "/dashboard/settings"
|
||||
| "/dashboard/"
|
||||
| "/dashboard/admin/analytics"
|
||||
| "/dashboard/admin/payments"
|
||||
| "/dashboard/admin/referrals"
|
||||
@@ -293,13 +375,33 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AuthRoute: typeof AuthRouteWithChildren
|
||||
AboutRoute: typeof AboutRoute
|
||||
BlogRoute: typeof BlogRoute
|
||||
ContactRoute: typeof ContactRoute
|
||||
DashboardRoute: typeof DashboardRouteWithChildren
|
||||
DemoRoute: typeof DemoRoute
|
||||
KvkkRoute: typeof KvkkRoute
|
||||
PricingRoute: typeof PricingRoute
|
||||
PrivacyRoute: typeof PrivacyRoute
|
||||
TermsRoute: typeof TermsRoute
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/terms": {
|
||||
id: "/terms"
|
||||
path: "/terms"
|
||||
fullPath: "/terms"
|
||||
preLoaderRoute: typeof TermsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/privacy": {
|
||||
id: "/privacy"
|
||||
path: "/privacy"
|
||||
fullPath: "/privacy"
|
||||
preLoaderRoute: typeof PrivacyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/pricing": {
|
||||
id: "/pricing"
|
||||
path: "/pricing"
|
||||
@@ -307,6 +409,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof PricingRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/kvkk": {
|
||||
id: "/kvkk"
|
||||
path: "/kvkk"
|
||||
fullPath: "/kvkk"
|
||||
preLoaderRoute: typeof KvkkRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/demo": {
|
||||
id: "/demo"
|
||||
path: "/demo"
|
||||
@@ -321,6 +430,27 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DashboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/contact": {
|
||||
id: "/contact"
|
||||
path: "/contact"
|
||||
fullPath: "/contact"
|
||||
preLoaderRoute: typeof ContactRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/blog": {
|
||||
id: "/blog"
|
||||
path: "/blog"
|
||||
fullPath: "/blog"
|
||||
preLoaderRoute: typeof BlogRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/about": {
|
||||
id: "/about"
|
||||
path: "/about"
|
||||
fullPath: "/about"
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/_auth": {
|
||||
id: "/_auth"
|
||||
path: ""
|
||||
@@ -335,6 +465,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
"/dashboard/": {
|
||||
id: "/dashboard/"
|
||||
path: "/"
|
||||
fullPath: "/dashboard/"
|
||||
preLoaderRoute: typeof DashboardIndexRouteImport
|
||||
parentRoute: typeof DashboardRoute
|
||||
}
|
||||
"/dashboard/settings": {
|
||||
id: "/dashboard/settings"
|
||||
path: "/settings"
|
||||
@@ -478,6 +615,7 @@ interface DashboardRouteChildren {
|
||||
DashboardHistoryRoute: typeof DashboardHistoryRoute
|
||||
DashboardSearchRoute: typeof DashboardSearchRoute
|
||||
DashboardSettingsRoute: typeof DashboardSettingsRoute
|
||||
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||
DashboardAdminAnalyticsRoute: typeof DashboardAdminAnalyticsRoute
|
||||
DashboardAdminPaymentsRoute: typeof DashboardAdminPaymentsRoute
|
||||
DashboardAdminReferralsRoute: typeof DashboardAdminReferralsRoute
|
||||
@@ -494,6 +632,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
|
||||
DashboardHistoryRoute: DashboardHistoryRoute,
|
||||
DashboardSearchRoute: DashboardSearchRoute,
|
||||
DashboardSettingsRoute: DashboardSettingsRoute,
|
||||
DashboardIndexRoute: DashboardIndexRoute,
|
||||
DashboardAdminAnalyticsRoute: DashboardAdminAnalyticsRoute,
|
||||
DashboardAdminPaymentsRoute: DashboardAdminPaymentsRoute,
|
||||
DashboardAdminReferralsRoute: DashboardAdminReferralsRoute,
|
||||
@@ -513,9 +652,15 @@ const DashboardRouteWithChildren = DashboardRoute._addFileChildren(
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AuthRoute: AuthRouteWithChildren,
|
||||
AboutRoute: AboutRoute,
|
||||
BlogRoute: BlogRoute,
|
||||
ContactRoute: ContactRoute,
|
||||
DashboardRoute: DashboardRouteWithChildren,
|
||||
DemoRoute: DemoRoute,
|
||||
KvkkRoute: KvkkRoute,
|
||||
PricingRoute: PricingRoute,
|
||||
PrivacyRoute: PrivacyRoute,
|
||||
TermsRoute: TermsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
||||
import { Database, ShieldCheck, Zap } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/_auth")({
|
||||
component: AuthLayout,
|
||||
@@ -6,8 +7,104 @@ export const Route = createFileRoute("/_auth")({
|
||||
|
||||
function AuthLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
|
||||
<Outlet />
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Panel — Form */}
|
||||
<div className="flex w-full flex-col justify-between px-6 py-8 lg:w-1/2">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center pt-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Sase.tr
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel — Promo (always dark, hidden on mobile) */}
|
||||
<div className="hidden border-l border-white/5 bg-[#09090b] text-white lg:flex lg:w-1/2 lg:flex-col lg:justify-between lg:px-12 lg:py-12">
|
||||
<div className="flex flex-1 flex-col justify-center space-y-8">
|
||||
{/* Heading */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Doğru Parçayı İlk Seferde Bulun
|
||||
</h2>
|
||||
<p className="text-base leading-relaxed text-neutral-400">
|
||||
Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM
|
||||
kodları. Şase numarasını girin, doğru parçayı saniyeler içinde
|
||||
bulun.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<Zap className="size-3" />
|
||||
1.2sn Sorgu
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<svg
|
||||
className="size-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2" />
|
||||
<circle cx="7" cy="17" r="2" />
|
||||
<circle cx="17" cy="17" r="2" />
|
||||
</svg>
|
||||
27 Marka
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<Database className="size-3" />
|
||||
243K+ OEM Parça
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-neutral-300">
|
||||
<ShieldCheck className="size-3" />
|
||||
%99.9 Uptime
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Brand row */}
|
||||
<p className="text-sm text-neutral-500">
|
||||
BMW · Mercedes-Benz · Audi · VW · Fiat · Renault · Toyota · Honda ·
|
||||
Hyundai · Ford · Opel · Skoda
|
||||
</p>
|
||||
|
||||
{/* Testimonial */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<p className="text-sm leading-relaxed text-neutral-300">
|
||||
“Sase.tr'ye geçtiğimizden beri yanlış parça
|
||||
siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde
|
||||
zaman tasarrufu sağlıyoruz.”
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-white/10 text-sm font-medium">
|
||||
MK
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Mehmet K.</p>
|
||||
<p className="text-xs text-neutral-400">
|
||||
Yedek Parça İşletme Sahibi
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom trial badge */}
|
||||
<div className="flex items-center gap-2 pt-6 text-sm text-neutral-400">
|
||||
<ShieldCheck className="size-4" />
|
||||
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -36,57 +35,63 @@ function ForgotPasswordPage() {
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>E-posta Gönderildi</CardTitle>
|
||||
<CardDescription>
|
||||
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen e-postanızı kontrol edin.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link to="/login">
|
||||
<Button variant="outline" className="w-full">
|
||||
Giriş Sayfasına Dön
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
E-posta Gönderildi
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Şifre sıfırlama bağlantısı {email} adresine gönderildi. Lütfen
|
||||
e-postanızı kontrol edin.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/login">
|
||||
<Button variant="outline" className="w-full">
|
||||
Giriş Sayfasına Dön
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Şifremi Unuttum</CardTitle>
|
||||
<CardDescription>E-posta adresinize şifre sıfırlama bağlantısı göndereceğiz</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="ornek@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Gönderiliyor..." : "Bağlantı Gönder"}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<Link to="/login" className="text-muted-foreground hover:underline">
|
||||
Giriş Sayfasına Dön
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Şifremi Unuttum
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
E-posta adresinize şifre sıfırlama bağlantısı göndereceğiz
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="ornek@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Gönderiliyor..." : "Bağlantı Gönder"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-muted-foreground hover:underline"
|
||||
>
|
||||
Giriş Sayfasına Dön
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,72 +31,92 @@ function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Giriş Yap</CardTitle>
|
||||
<CardDescription>Sase.tr hesabınıza giriş yapın</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="ornek@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Hesabınıza Giriş Yapın
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Şase çözme ve parça kataloğuna erişmek için giriş yapın
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">veya</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="ornek@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn.social({ provider: "google" })}
|
||||
>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
</div>
|
||||
{/* Remember me + Forgot password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-input accent-primary"
|
||||
/>
|
||||
Beni hatırla
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
Şifremi Unuttum
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<Link to="/forgot-password" className="text-muted-foreground hover:underline">
|
||||
Şifremi Unuttum
|
||||
</Link>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Giriş yapılıyor..." : "Giriş Yap"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Divider + Google */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="mt-2 text-center text-sm">
|
||||
Hesabınız yok mu?{" "}
|
||||
<Link to="/register" className="font-medium hover:underline">
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
veya
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn.social({ provider: "google" })}
|
||||
>
|
||||
Google ile Giriş Yap
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Register link */}
|
||||
<p className="text-center text-sm">
|
||||
Hesabınız yok mu?{" "}
|
||||
<Link to="/register" className="font-medium hover:underline">
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { signUp } from "@/lib/auth-client";
|
||||
import { toast } from "sonner";
|
||||
@@ -40,71 +39,74 @@ function RegisterPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Kayıt Ol</CardTitle>
|
||||
<CardDescription>Yeni bir Sase.tr hesabı oluşturun</CardDescription>
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Kayıt Ol
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Yeni bir Sase.tr hesabı oluşturun
|
||||
</p>
|
||||
|
||||
{/* Trial messaging */}
|
||||
<div className="mt-3 flex items-center justify-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Ad Soyad</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Ad Soyad"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="ornek@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="En az 8 karakter, 1 büyük harf, 1 rakam"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Kayıt yapılıyor..." : "Kayıt Ol"}
|
||||
</Button>
|
||||
</form>
|
||||
{/* Trial messaging */}
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-600 dark:text-emerald-400">
|
||||
<ShieldCheck className="size-4 shrink-0" />
|
||||
7 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-muted-foreground">
|
||||
Kayıt olunca hemen VIN aramaya başlayın
|
||||
</p>
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Ad Soyad</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Ad Soyad"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="ornek@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="En az 8 karakter, 1 büyük harf, 1 rakam"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Kayıt yapılıyor..." : "Kayıt Ol"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Zaten hesabınız var mı?{" "}
|
||||
<Link to="/login" className="font-medium hover:underline">
|
||||
Giriş Yap
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Kayıt olunca hemen VIN aramaya başlayın
|
||||
</p>
|
||||
|
||||
<p className="text-center text-sm">
|
||||
Zaten hesabınız var mı?{" "}
|
||||
<Link to="/login" className="font-medium hover:underline">
|
||||
Giriş Yap
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||
import { Label } from "@sase/ui";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -32,39 +31,44 @@ function ResetPasswordPage() {
|
||||
toast.success("Şifreniz başarıyla güncellendi.");
|
||||
navigate({ to: "/login" });
|
||||
} catch {
|
||||
toast.error("Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.");
|
||||
toast.error(
|
||||
"Şifre sıfırlama başarısız. Bağlantı süresi dolmuş olabilir.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Şifre Sıfırla</CardTitle>
|
||||
<CardDescription>Yeni şifrenizi belirleyin</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Yeni Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="En az 8 karakter"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Güncelleniyor..." : "Şifreyi Güncelle"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-8">
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
Şifre Sıfırla
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Yeni şifrenizi belirleyin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Yeni Şifre</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="En az 8 karakter"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Güncelleniyor..." : "Şifreyi Güncelle"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
87
apps/web/src/routes/about.tsx
Normal file
87
apps/web/src/routes/about.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
|
||||
export const Route = createFileRoute("/about")({
|
||||
component: AboutPage,
|
||||
});
|
||||
|
||||
function AboutPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost">Giriş Yap</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button>Kayıt Ol</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Hakkımızda</h1>
|
||||
|
||||
<div className="mt-8 space-y-6 text-muted-foreground leading-relaxed">
|
||||
<p>
|
||||
Sase.tr, Türkiye'nin yedek parça sektörüne yönelik geliştirilen
|
||||
dijital bir platformdur. Şase numarası (VIN) ile araç tanımlama,
|
||||
orijinal parça kataloğuna erişim ve interaktif şema görüntüleme
|
||||
hizmetlerini tek bir çatı altında sunar.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Misyonumuz</h2>
|
||||
<p>
|
||||
Yedek parça arama sürecini hızlandırmak, doğru parçaya ilk
|
||||
seferde ulaşmayı sağlamak ve sektördeki bilgi asimetrisini ortadan
|
||||
kaldırmak. Oto yedek parçacılar, servisler ve bireysel kullanıcılar
|
||||
için güvenilir bir referans noktası olmayı hedefliyoruz.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Ne Yapıyoruz?</h2>
|
||||
<ul className="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Şase Çözme:</strong> VIN numarası ile aracın marka,
|
||||
model, yıl, motor tipi ve donanım bilgilerine anında erişim.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Orijinal Parça Kataloğu:</strong> OEM bazlı parça
|
||||
numaraları, açıklamalar ve çapraz referanslar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>İnteraktif Şema:</strong> Araç şemaları üzerinden
|
||||
görsel parça seçimi ve detay görüntüleme.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Çoklu Marka Desteği:</strong> Volkswagen, Audi, BMW,
|
||||
Mercedes-Benz, Ford ve daha fazlası.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">Neden Sase.tr?</h2>
|
||||
<p>
|
||||
Geleneksel yöntemlerle saatler süren parça arama işlemini
|
||||
dakikalara indiriyoruz. Güncel ve doğrulanmış verilerle yanlış
|
||||
parça siparişinin önüne geçiyoruz. Kullanıcı dostu arayüzümüz
|
||||
sayesinde teknik bilgi seviyesinden bağımsız olarak herkes
|
||||
kolayca kullanabilir.
|
||||
</p>
|
||||
|
||||
<h2 className="text-2xl font-semibold text-foreground">İletişim</h2>
|
||||
<p>
|
||||
Sorularınız veya iş birliği talepleriniz için{" "}
|
||||
<a href="mailto:info@sase.tr" className="text-primary underline">
|
||||
info@sase.tr
|
||||
</a>{" "}
|
||||
adresinden bize ulaşabilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
apps/web/src/routes/blog.tsx
Normal file
83
apps/web/src/routes/blog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@sase/ui";
|
||||
|
||||
export const Route = createFileRoute("/blog")({
|
||||
component: BlogPage,
|
||||
});
|
||||
|
||||
const posts = [
|
||||
{
|
||||
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
|
||||
description:
|
||||
"17 haneli VIN kodunun her bir bölümünün ne anlama geldiğini, araç geçmişini nasıl ortaya çıkarabileceğinizi adım adım anlatıyoruz.",
|
||||
date: "2026-02-10",
|
||||
slug: "vin-nedir-nasil-okunur",
|
||||
},
|
||||
{
|
||||
title: "Orijinal Parça mı, Muadil Parça mı?",
|
||||
description:
|
||||
"OEM ve aftermarket parçalar arasındaki farkları, avantaj ve dezavantajlarını karşılaştırmalı olarak inceliyoruz.",
|
||||
date: "2026-01-28",
|
||||
slug: "orijinal-mi-muadil-mi",
|
||||
},
|
||||
{
|
||||
title: "Yedek Parça Aramada Dijital Dönüşüm",
|
||||
description:
|
||||
"Geleneksel katalog yöntemlerinden dijital platformlara geçiş sürecini ve sektöre etkilerini ele alıyoruz.",
|
||||
date: "2026-01-15",
|
||||
slug: "dijital-donusum",
|
||||
},
|
||||
{
|
||||
title: "Sase.tr ile Doğru Parçayı İlk Seferde Bulun",
|
||||
description:
|
||||
"Platform özelliklerini kullanarak VIN bazlı arama, şema görüntüleme ve parça eşleştirme rehberi.",
|
||||
date: "2026-01-05",
|
||||
slug: "dogru-parcayi-bulun",
|
||||
},
|
||||
];
|
||||
|
||||
function BlogPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost">Giriş Yap</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button>Kayıt Ol</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-4xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Blog</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Yedek parça sektörü, araç bakımı ve Sase.tr hakkında güncel yazılar.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
{posts.map((post) => (
|
||||
<Card key={post.slug}>
|
||||
<CardHeader>
|
||||
<CardDescription>{post.date}</CardDescription>
|
||||
<CardTitle className="text-lg">{post.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{post.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/routes/contact.tsx
Normal file
87
apps/web/src/routes/contact.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@sase/ui";
|
||||
|
||||
export const Route = createFileRoute("/contact")({
|
||||
component: ContactPage,
|
||||
});
|
||||
|
||||
function ContactPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost">Giriş Yap</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button>Kayıt Ol</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">İletişim</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Sorularınız, önerileriniz veya iş birliği talepleriniz için bize
|
||||
ulaşın.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">E-posta</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href="mailto:info@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
info@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Genel sorular ve destek talepleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Destek</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<a
|
||||
href="mailto:destek@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
destek@sase.tr
|
||||
</a>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Teknik sorunlar ve hesap işlemleri için.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="sm:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Adres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
İstanbul, Türkiye
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Çalışma saatleri: Pazartesi – Cuma, 09:00 – 18:00
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createFileRoute, Outlet, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { createFileRoute, Outlet, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { Button, Separator, Skeleton } from "@sase/ui";
|
||||
import {
|
||||
Search,
|
||||
History,
|
||||
@@ -19,6 +18,13 @@ import {
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
LayoutDashboard,
|
||||
Bell,
|
||||
ArrowRight,
|
||||
Mail,
|
||||
BookOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
@@ -27,12 +33,23 @@ export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardLayout,
|
||||
});
|
||||
|
||||
const navItems = [
|
||||
{ to: "/dashboard/search", label: "nav.search", icon: Search },
|
||||
{ to: "/dashboard/history", label: "nav.history", icon: History },
|
||||
{ to: "/dashboard/subscription", label: "nav.subscription", icon: CreditCard },
|
||||
{ to: "/dashboard/billing", label: "nav.billing", icon: Receipt },
|
||||
{ to: "/dashboard/settings", label: "nav.settings", icon: Settings },
|
||||
// ─── NAV SECTIONS ─────────────────────────────────────────────────────────────
|
||||
|
||||
const mainMenuItems = [
|
||||
{ to: "/dashboard", label: "Gösterge Paneli", icon: LayoutDashboard, exact: true },
|
||||
{ to: "/dashboard/search", label: "nav.search", translatable: true, icon: Search },
|
||||
{ to: "/dashboard/history", label: "nav.history", translatable: true, icon: History },
|
||||
] as const;
|
||||
|
||||
const accountItems = [
|
||||
{ to: "/dashboard/subscription", label: "nav.subscription", translatable: true, icon: CreditCard },
|
||||
{ to: "/dashboard/billing", label: "nav.billing", translatable: true, icon: Receipt },
|
||||
{ to: "/dashboard/settings", label: "nav.settings", translatable: true, icon: Settings },
|
||||
] as const;
|
||||
|
||||
const supportItems = [
|
||||
{ to: "/contact", label: "İletişim", icon: Mail },
|
||||
{ to: "/blog", label: "Blog", icon: BookOpen },
|
||||
] as const;
|
||||
|
||||
const adminItems = [
|
||||
@@ -43,12 +60,79 @@ const adminItems = [
|
||||
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },
|
||||
] as const;
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function NavSection({
|
||||
title,
|
||||
collapsed,
|
||||
}: {
|
||||
title: string;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
if (collapsed) {
|
||||
return <Separator className="my-2 bg-border/50" />;
|
||||
}
|
||||
return (
|
||||
<p className="mb-1 mt-4 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
{title}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
collapsed,
|
||||
onClick,
|
||||
exact,
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
collapsed: boolean;
|
||||
onClick?: () => void;
|
||||
exact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
title={collapsed ? label : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── LAYOUT ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function DashboardLayout() {
|
||||
const { t } = useTranslation();
|
||||
const { user, isLoading, signOut, isAdmin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => getUserSettings().sidebarCollapsed ?? false,
|
||||
);
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
if (theme === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return theme === "dark";
|
||||
});
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = isDark ? "light" : "dark";
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
setUserSetting("theme", next);
|
||||
setIsDark(next === "dark");
|
||||
};
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
@@ -59,7 +143,7 @@ function DashboardLayout() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<div className="hidden w-64 border-r p-4 lg:block">
|
||||
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
|
||||
<Skeleton className="mb-8 h-8 w-32" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={`nav-skel-${i}`} className="mb-3 h-10 w-full" />
|
||||
@@ -77,109 +161,224 @@ function DashboardLayout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initials = user.name
|
||||
? user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
// Resolve labels (some need translation, some are static)
|
||||
function resolveLabel(item: { label: string; translatable?: boolean }) {
|
||||
return item.translatable ? t(item.label) : item.label;
|
||||
}
|
||||
|
||||
// Sidebar content (shared between desktop & mobile)
|
||||
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
return (
|
||||
<>
|
||||
{/* ANA MENÜ */}
|
||||
<NavSection title="Ana Menü" collapsed={collapsed && !onNavigate} />
|
||||
{mainMenuItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={resolveLabel(item)}
|
||||
icon={item.icon}
|
||||
collapsed={collapsed && !onNavigate}
|
||||
onClick={onNavigate}
|
||||
exact={(item as any).exact}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* DESTEK */}
|
||||
<NavSection title="Destek" collapsed={collapsed && !onNavigate} />
|
||||
{supportItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={resolveLabel(item)}
|
||||
icon={item.icon}
|
||||
collapsed={collapsed && !onNavigate}
|
||||
onClick={onNavigate}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* HESAP */}
|
||||
<NavSection title="Hesap" collapsed={collapsed && !onNavigate} />
|
||||
{accountItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={resolveLabel(item)}
|
||||
icon={item.icon}
|
||||
collapsed={collapsed && !onNavigate}
|
||||
onClick={onNavigate}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ADMIN */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<NavSection title="Yönetim" collapsed={collapsed && !onNavigate} />
|
||||
{adminItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
collapsed={collapsed && !onNavigate}
|
||||
onClick={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* ─── Desktop Sidebar ──────────────────────────────────────────── */}
|
||||
<aside
|
||||
className={`hidden flex-shrink-0 border-r transition-[width] duration-200 lg:block ${collapsed ? "w-16" : "w-64"}`}
|
||||
className={`hidden flex-shrink-0 border-r border-border transition-[width] duration-200 lg:flex lg:flex-col ${collapsed ? "w-[68px]" : "w-64"}`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={`flex h-16 items-center border-b ${collapsed ? "justify-center px-2" : "justify-between px-4"}`}>
|
||||
{!collapsed && (
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
{/* Logo + Collapse */}
|
||||
<div
|
||||
className={`flex h-16 items-center border-b border-border ${collapsed ? "justify-center px-2" : "justify-between px-4"}`}
|
||||
>
|
||||
{!collapsed && (
|
||||
<Link to="/" className="text-xl font-bold tracking-tight">
|
||||
Sase.tr
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="size-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="size-5" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="h-5 w-5" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<nav className={`flex-1 space-y-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"}`}>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
title={collapsed ? t(item.label) : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{t(item.label)}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{isAdmin &&
|
||||
adminItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{/* User section - bottom */}
|
||||
<div className={`border-t ${collapsed ? "p-2" : "p-3"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
title={collapsed ? user.name ?? "Çıkış" : undefined}
|
||||
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2" : "gap-3 px-3 py-2"}`}
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className={`flex-1 space-y-0.5 overflow-y-auto ${collapsed ? "p-2" : "px-3 py-2"}`}
|
||||
>
|
||||
<SidebarNav />
|
||||
</nav>
|
||||
|
||||
{/* User Profile - Bottom */}
|
||||
<div className={`border-t border-border ${collapsed ? "p-2" : "p-3"}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
title={collapsed ? user.name ?? "Çıkış" : undefined}
|
||||
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5"}`}
|
||||
>
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<LogOut className="size-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
{/* ─── Main Content Area ────────────────────────────────────────── */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center border-b px-6 lg:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
{/* Top Header Bar */}
|
||||
<header className="flex h-16 items-center justify-between border-b border-border px-4 sm:px-6">
|
||||
{/* Left: Mobile menu + User welcome */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden size-10 items-center justify-center rounded-full bg-muted text-sm font-semibold sm:flex">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sase.tr'ye hoş geldiniz 👋
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/dashboard/subscription">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden rounded-full border-border text-xs sm:inline-flex"
|
||||
>
|
||||
<CreditCard className="mr-1.5 size-3.5" />
|
||||
Plan Yükselt
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
aria-label="Tema değiştir"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground lg:hidden"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<main className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border px-6 py-3">
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası,
|
||||
Kullanım Koşulları
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Nav Overlay */}
|
||||
{/* ─── Mobile Nav Overlay ───────────────────────────────────────── */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-50 lg:hidden">
|
||||
<div
|
||||
@@ -190,63 +389,39 @@ function DashboardLayout() {
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
|
||||
<div className="flex h-16 items-center justify-between border-b px-6">
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
<span className="text-xl font-bold">Sase.tr</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent"
|
||||
activeProps={{ className: "active" }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(item.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{isAdmin &&
|
||||
adminItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent"
|
||||
activeProps={{ className: "active" }}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-2">
|
||||
<SidebarNav onNavigate={() => setMobileOpen(false)} />
|
||||
</nav>
|
||||
{/* User section - bottom */}
|
||||
<div className="border-t p-3">
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
signOut();
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent"
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{user.name?.charAt(0).toUpperCase() ?? "?"}
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<LogOut className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<LogOut className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -160,7 +160,7 @@ function AdminAnalyticsPage() {
|
||||
<div className="min-w-[900px]">
|
||||
<div className="grid grid-cols-8 items-center gap-4 border-b px-6 py-3 text-sm font-medium text-muted-foreground">
|
||||
<span>Kullanici</span>
|
||||
<span>VIN</span>
|
||||
<span>Şase</span>
|
||||
<span>Marka</span>
|
||||
<span>Kaynak</span>
|
||||
<span className="text-center">Durum</span>
|
||||
|
||||
@@ -34,7 +34,7 @@ function HistoryPage() {
|
||||
Henüz arama yapmadınız.
|
||||
<br />
|
||||
<Link to="/dashboard/search" className="text-primary hover:underline">
|
||||
VIN arama sayfasına gidin
|
||||
Şase arama sayfasına gidin
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
461
apps/web/src/routes/dashboard/index.tsx
Normal file
461
apps/web/src/routes/dashboard/index.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, Badge, Skeleton, Separator } from "@sase/ui";
|
||||
import {
|
||||
Search,
|
||||
Car,
|
||||
Database,
|
||||
User,
|
||||
ArrowRight,
|
||||
Crown,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard/")({
|
||||
component: DashboardHome,
|
||||
});
|
||||
|
||||
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DashboardStats {
|
||||
totalSearches: number;
|
||||
monthlySearches: number;
|
||||
totalVehicles: number;
|
||||
recentVehicles: number;
|
||||
}
|
||||
|
||||
interface SubscriptionBrand {
|
||||
brandId: string;
|
||||
brandName: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
status: string;
|
||||
plan?: { name: string; key: string };
|
||||
billingPeriod: string;
|
||||
brands?: SubscriptionBrand[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
// ─── STAT CARD ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
detail,
|
||||
detailValue,
|
||||
buttonLabel,
|
||||
buttonTo,
|
||||
progress,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
value: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
detailValue?: string;
|
||||
buttonLabel?: string;
|
||||
buttonTo?: string;
|
||||
progress?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
{/* Icon + Value + Label */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="inline-flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<Icon className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-medium text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{detail}</span>
|
||||
<span className="font-medium">{detailValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<Separator className="my-4 bg-border" />
|
||||
|
||||
{/* Progress bar or button */}
|
||||
{progress !== undefined ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Kullanım</span>
|
||||
<span className="font-semibold">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : buttonLabel && buttonTo ? (
|
||||
<Link to={buttonTo}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center rounded-lg border-border text-sm"
|
||||
>
|
||||
{buttonLabel}
|
||||
<ArrowRight className="ml-1.5 size-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PROFILE CARD ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileCard({
|
||||
name,
|
||||
email,
|
||||
initials,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
initials: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="inline-flex size-12 items-center justify-center rounded-xl bg-muted">
|
||||
<User className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4 bg-border" />
|
||||
|
||||
<Link to="/dashboard/settings">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center rounded-lg border-border text-sm"
|
||||
>
|
||||
Profil Ayarları
|
||||
<ArrowRight className="ml-1.5 size-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PAGE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function DashboardHome() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ["dashboard", "stats"],
|
||||
queryFn: async () => {
|
||||
// Try to get stats from the API, fall back to defaults
|
||||
try {
|
||||
return await api.get<DashboardStats>("/dashboard/stats");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { data: subscription, isLoading: subLoading } = useQuery({
|
||||
queryKey: ["subscription", "me"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<Subscription>("/subscriptions/me");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await api.get<any[]>("/vehicles/history?limit=5");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const initials = user?.name
|
||||
? user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
const totalSearches = stats?.totalSearches ?? history?.length ?? 0;
|
||||
const monthlySearches = stats?.monthlySearches ?? 0;
|
||||
const totalVehicles = stats?.totalVehicles ?? history?.length ?? 0;
|
||||
const recentVehicles = stats?.recentVehicles ?? 0;
|
||||
const brandCount = subscription?.brands?.length ?? 0;
|
||||
const maxBrands =
|
||||
subscription?.plan?.key === "full"
|
||||
? 27
|
||||
: subscription?.plan?.key === "brand3"
|
||||
? 3
|
||||
: subscription?.plan?.key === "brand2"
|
||||
? 2
|
||||
: subscription?.plan?.key === "brand1"
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const isLoadingCards = statsLoading || subLoading;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
{/* ─── STAT CARDS ─────────────────────────────────────────────── */}
|
||||
{isLoadingCards ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={`card-skel-${i}`} className="h-64 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 1. Toplam Arama */}
|
||||
<StatCard
|
||||
icon={Search}
|
||||
value={String(totalSearches)}
|
||||
label="Toplam Arama"
|
||||
detail="Bu Ay"
|
||||
detailValue={String(monthlySearches)}
|
||||
buttonLabel="Şase Çöz"
|
||||
buttonTo="/dashboard/search"
|
||||
/>
|
||||
|
||||
{/* 2. Araç Kaydı */}
|
||||
<StatCard
|
||||
icon={Car}
|
||||
value={String(totalVehicles)}
|
||||
label="Araç Kaydı"
|
||||
detail="Son 30 Gün"
|
||||
detailValue={String(recentVehicles)}
|
||||
buttonLabel="Geçmişe Git"
|
||||
buttonTo="/dashboard/history"
|
||||
/>
|
||||
|
||||
{/* 3. Aktif Marka */}
|
||||
<StatCard
|
||||
icon={Database}
|
||||
value={maxBrands > 0 ? `${brandCount}/${maxBrands}` : "—"}
|
||||
label="Aktif Marka"
|
||||
detail="Erişilebilir Marka"
|
||||
detailValue={maxBrands > 0 ? String(maxBrands) : "—"}
|
||||
progress={
|
||||
maxBrands > 0 ? Math.round((brandCount / maxBrands) * 100) : undefined
|
||||
}
|
||||
buttonLabel={maxBrands === 0 ? "Plan Seçin" : undefined}
|
||||
buttonTo={maxBrands === 0 ? "/dashboard/subscription" : undefined}
|
||||
/>
|
||||
|
||||
{/* 4. Profil */}
|
||||
<ProfileCard
|
||||
name={user?.name ?? "—"}
|
||||
email={user?.email ?? "—"}
|
||||
initials={initials}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── ACTIVE SUBSCRIPTION ────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
Aktif Aboneliğiniz
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Şase çözme ve parça kataloğuna tek tıkla erişin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{subLoading ? (
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
) : subscription && subscription.status === "active" ? (
|
||||
<div className="rounded-2xl border border-border bg-background p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
{/* Plan Info */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Crown className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold">
|
||||
{subscription.plan?.name ?? "Aktif Plan"}
|
||||
</h3>
|
||||
<Badge variant="default" className="bg-emerald-600 text-xs">
|
||||
Aktif
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{subscription.billingPeriod === "yearly"
|
||||
? "Yıllık"
|
||||
: "Aylık"}{" "}
|
||||
abonelik
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brands */}
|
||||
{subscription.brands && subscription.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.brands.map((b) => (
|
||||
<Badge key={b.brandId} variant="outline">
|
||||
{b.brandName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
{subscription.startDate && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
Başlangıç:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(subscription.startDate).toLocaleDateString(
|
||||
"tr-TR",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{subscription.endDate && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Calendar className="size-3.5" />
|
||||
Bitiş:{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{new Date(subscription.endDate).toLocaleDateString(
|
||||
"tr-TR",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{[
|
||||
"Sınırsız şase arama",
|
||||
"Parça kataloğu",
|
||||
"İnteraktif şema",
|
||||
].map((f) => (
|
||||
<span
|
||||
key={f}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link to="/dashboard/search">
|
||||
<Button className="w-full rounded-lg sm:w-auto">
|
||||
Şase Ara
|
||||
<ArrowRight className="ml-1.5 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/dashboard/subscription">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-lg border-border text-sm sm:w-auto"
|
||||
>
|
||||
Plan Detayları
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* No subscription */
|
||||
<div className="rounded-2xl border border-dashed border-border bg-background p-8 text-center">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Crown className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold">Henüz aktif planınız yok</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Şase çözme, parça kataloğu ve interaktif şemalara erişmek için bir plan seçin.
|
||||
</p>
|
||||
<Link to="/dashboard/subscription">
|
||||
<Button className="mt-4 rounded-lg">
|
||||
Plan Seçin
|
||||
<ArrowRight className="ml-1.5 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── SON ARAMALAR ───────────────────────────────────────────── */}
|
||||
{history && history.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
Son Aramalar
|
||||
</h2>
|
||||
<Link
|
||||
to="/dashboard/history"
|
||||
className="text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
Tümünü Gör →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{history.slice(0, 3).map((v: any) => (
|
||||
<Link
|
||||
key={v.id}
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id: v.id }}
|
||||
>
|
||||
<div className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 transition-colors hover:bg-accent">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Car className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{v.brandName} {v.model}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{v.vin}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{v.year}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Input } from "@sase/ui";
|
||||
import { Card, CardContent } from "@sase/ui";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button, Input, Badge, Separator } from "@sase/ui";
|
||||
import {
|
||||
Search,
|
||||
Car,
|
||||
Loader2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
|
||||
|
||||
function isValidVin(vin: string): boolean {
|
||||
if (!vin || vin.length !== 17) return false;
|
||||
return VIN_REGEX.test(vin.toUpperCase());
|
||||
}
|
||||
|
||||
/** Auto-correct I→1, O→0, Q→9 and return which chars were fixed */
|
||||
function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
|
||||
const corrections: string[] = [];
|
||||
const cleaned = raw.replace(/[IOQioq]/g, (ch) => {
|
||||
const upper = ch.toUpperCase();
|
||||
if (upper === "I") { corrections.push("I→1"); return "1"; }
|
||||
if (upper === "O") { corrections.push("O→0"); return "0"; }
|
||||
corrections.push("Q→9"); return "9";
|
||||
});
|
||||
return { cleaned, corrections };
|
||||
}
|
||||
|
||||
// ─── ROUTE ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Route = createFileRoute("/dashboard/search")({
|
||||
component: SearchPage,
|
||||
});
|
||||
|
||||
function SearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [vin, setVin] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
// Live preview state
|
||||
const [preview, setPreview] = useState<{
|
||||
brandName: string;
|
||||
model: string;
|
||||
year: string;
|
||||
engine: string;
|
||||
source?: string;
|
||||
} | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: ["vehicles", "history"],
|
||||
queryFn: () => api.get<any[]>("/vehicles/history?limit=20"),
|
||||
});
|
||||
|
||||
// ─── Ctrl+K shortcut ───────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// ─── VIN preview fetch (17 chars) ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (vin.length !== 17) {
|
||||
setPreview(null);
|
||||
setPreviewError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(false);
|
||||
|
||||
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Not found");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const r = data.data !== undefined ? data.data : data;
|
||||
if (r?.brandName) {
|
||||
setPreview({
|
||||
brandName: r.brandName,
|
||||
model: r.model || "—",
|
||||
year: r.year ? String(r.year) : "—",
|
||||
engine: r.engine || "—",
|
||||
source: r.source,
|
||||
});
|
||||
} else {
|
||||
setPreviewError(true);
|
||||
}
|
||||
setPreviewLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
setPreviewError(true);
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [vin]);
|
||||
|
||||
// ─── Auto-focus ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// ─── Submit ────────────────────────────────────────────────────────────────
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const cleanVin = vin.toUpperCase().trim();
|
||||
if (!isValidVin(cleanVin)) {
|
||||
setError("Geçersiz VIN. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
|
||||
setError(
|
||||
"Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,68 +147,224 @@ function SearchPage() {
|
||||
} else {
|
||||
setError("Bir hata oluştu. Lütfen tekrar deneyin.");
|
||||
}
|
||||
toast.error("VIN arama başarısız");
|
||||
toast.error("Şase arama başarısız");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleVinChange(raw: string) {
|
||||
const upper = raw.toUpperCase();
|
||||
const { cleaned, corrections } = sanitizeVin(upper);
|
||||
setVin(cleaned);
|
||||
setError(null);
|
||||
if (corrections.length > 0) {
|
||||
const unique = [...new Set(corrections)];
|
||||
toast.info(`Otomatik düzeltildi: ${unique.join(", ")}`, {
|
||||
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
|
||||
duration: 2500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function fillExampleVin() {
|
||||
setVin("WVWZZZ1JZ3W597935");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">VIN Arama</h2>
|
||||
<p className="text-muted-foreground">Araç VIN numarasını girerek yedek parça kataloğuna erişin</p>
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* ─── SECTION 1: Hero Input Card ─────────────────────────────────── */}
|
||||
<div className="rounded-2xl border border-border bg-background p-6 sm:p-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="inline-flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Search className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-2xl font-bold tracking-tight">
|
||||
Şase Arama
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Araç şase numarasını girerek yedek parça kataloğuna erişin
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6 bg-border" />
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSearch} className="space-y-4">
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Şase numarasını girin (17 karakter)"
|
||||
value={vin}
|
||||
onChange={(e) => handleVinChange(e.target.value)}
|
||||
maxLength={17}
|
||||
className={`h-14 rounded-xl bg-muted/50 pl-12 font-mono tracking-wider ${vin.length === 0 ? "pr-24" : "pr-4"}`}
|
||||
/>
|
||||
{vin.length === 0 && (
|
||||
<div className="pointer-events-none absolute right-4 top-1/2 flex -translate-y-1/2 items-center gap-1 text-xs text-muted-foreground">
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-[10px]">
|
||||
Ctrl
|
||||
</kbd>
|
||||
<span>+</span>
|
||||
<kbd className="rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-[10px]">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 17-segment progress bar */}
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: 17 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors duration-200 ${
|
||||
i < vin.length ? "bg-emerald-500" : "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Counter + Example VIN */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{vin.length}/17 karakter
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillExampleVin}
|
||||
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||||
>
|
||||
Örnek şase deneyin →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || vin.length !== 17}
|
||||
className="h-12 w-full rounded-xl"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-2 size-4" />
|
||||
)}
|
||||
Şase Çöz
|
||||
</Button>
|
||||
|
||||
{/* Error card */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-xl border border-destructive/30 bg-destructive/5 p-4">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSearch} className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="VIN numarasını girin (17 karakter)"
|
||||
value={vin}
|
||||
onChange={(e) => {
|
||||
setVin(e.target.value.toUpperCase());
|
||||
setError(null);
|
||||
}}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onBlur={() => setShowHistory(false)}
|
||||
maxLength={17}
|
||||
className="font-mono text-lg tracking-wider"
|
||||
/>
|
||||
{showHistory && vin.length === 0 && history && history.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 max-h-80 w-full overflow-y-auto rounded-md border bg-popover shadow-md">
|
||||
{history.map((v: any) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-accent"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setVin(v.vin);
|
||||
setShowHistory(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm">{v.vin}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{v.brandName} {v.model} {v.year}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || vin.length !== 17}>
|
||||
{loading ? (
|
||||
<span className="animate-spin">...</span>
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
Ara
|
||||
</Button>
|
||||
</form>
|
||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ─── SECTION 2: Live Preview Card ───────────────────────────────── */}
|
||||
{previewLoading && (
|
||||
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Araç bilgileri alınıyor...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && !previewLoading && (
|
||||
<div className="rounded-2xl border border-emerald-500/30 bg-background p-5 sm:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<Car className="size-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
|
||||
{preview.brandName} {preview.model}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{preview.year}
|
||||
{preview.engine !== "—" && ` — Motor: ${preview.engine}`}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-600 text-xs text-white"
|
||||
>
|
||||
Araç tanımlandı
|
||||
</Badge>
|
||||
{preview.source && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Kaynak: {preview.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4 bg-border" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* ─── SECTION 4: Son Aramalar ────────────────────────────────────── */}
|
||||
{history && history.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
<h3 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">
|
||||
Son Aramalar
|
||||
</h3>
|
||||
</div>
|
||||
<Link
|
||||
to="/dashboard/history"
|
||||
className="text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
Tümünü Gör →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{history.slice(0, 6).map((v: any) => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setVin(v.vin);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
<Car className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{v.brandName} {v.model}
|
||||
</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">
|
||||
{v.vin}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{v.year}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Geçmiş aramayı tıklayın — şase otomatik doldurulur
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
Moon,
|
||||
Loader2,
|
||||
Gift,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Player } from "@remotion/player";
|
||||
import { SchemaDemo } from "@/remotion/SchemaDemo";
|
||||
import { DashboardDemo } from "@/remotion/DashboardDemo";
|
||||
import { EcommerceDemo } from "@/remotion/EcommerceDemo";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
|
||||
// ─── DATA ────────────────────────────────────────────────────────────────────
|
||||
@@ -255,6 +257,37 @@ const COMPARISON = [
|
||||
{ label: "Aylık zaman tasarrufu", manual: "—", sase: "~40 saat" },
|
||||
];
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
name: "Ahmet Y.",
|
||||
role: "Yedek Parça Mağazası Sahibi",
|
||||
initials: "AY",
|
||||
text: "Eskiden bir parça aramak için 3-4 katalog açıyordum. Sase.tr ile şase numarasını giriyorum, 30 saniyede doğru OEM kodunu buluyorum. İade oranımız %80 düştü.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Mehmet K.",
|
||||
role: "Oto Servis İşletmecisi",
|
||||
initials: "MK",
|
||||
text: "Sase.tr'ye geçtiğimizden beri yanlış parça siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde zaman tasarrufu sağlıyoruz.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Özge D.",
|
||||
role: "E-Ticaret Yöneticisi",
|
||||
initials: "ÖD",
|
||||
text: "Online mağazamıza VIN entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü.",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
name: "Burak T.",
|
||||
role: "Filo Yöneticisi",
|
||||
initials: "BT",
|
||||
text: "200+ araçlık filomuz için parça yönetimi çok kolay hale geldi. Özellikle interaktif şema özelliği montaj sürecini çok hızlandırıyor.",
|
||||
rating: 5,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionBadge({ children }: { children: React.ReactNode }) {
|
||||
@@ -498,6 +531,21 @@ function HomePage() {
|
||||
Her yanlış parça siparişi ortalama 450 TL'ye mal olur
|
||||
</div>
|
||||
|
||||
{/* Social proof badge */}
|
||||
<div className="mt-3 inline-flex items-center gap-3 rounded-full border border-border bg-muted/50 px-4 py-2 text-xs text-muted-foreground">
|
||||
<div className="flex -space-x-2">
|
||||
{["AY", "MK", "ÖD", "BT"].map((initials) => (
|
||||
<div key={initials} className="flex size-6 items-center justify-center rounded-full border-2 border-background bg-muted text-[10px] font-medium">
|
||||
{initials}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||
<span>⭐ 4.9/5</span>
|
||||
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||
<span>500+ İşletme Güveniyor</span>
|
||||
</div>
|
||||
|
||||
<h1 className="font-[family-name:var(--font-display)] text-4xl font-bold leading-[1.1] tracking-tight sm:text-5xl lg:text-7xl">
|
||||
Müşteriniz Şase Verdi —
|
||||
<br />
|
||||
@@ -884,11 +932,13 @@ function HomePage() {
|
||||
</section>
|
||||
|
||||
{/* ─── 8. RAKAMLARLA SASE.TR ────────────────────────────────────── */}
|
||||
<section className="px-4 py-24 sm:px-6">
|
||||
<section className="bg-[#09090b] px-4 py-24 text-white sm:px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<SectionBadge>Platform</SectionBadge>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-neutral-400">
|
||||
Platform
|
||||
</span>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight text-white sm:text-4xl lg:text-5xl">
|
||||
Rakamlarla Sase.tr
|
||||
</h2>
|
||||
</div>
|
||||
@@ -897,12 +947,12 @@ function HomePage() {
|
||||
{STATS.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-2xl border border-border bg-surface p-6 text-center sm:p-8"
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center sm:p-8"
|
||||
>
|
||||
<p className="font-[family-name:var(--font-display)] text-4xl font-bold text-foreground sm:text-5xl">
|
||||
<p className="font-[family-name:var(--font-display)] text-4xl font-bold text-white sm:text-5xl">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-neutral-400">
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
@@ -963,6 +1013,142 @@ function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 9.5. E-TİCARET ENTEGRASYONU ────────────────────────────── */}
|
||||
<section className="px-4 py-24 sm:px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<SectionBadge>E-Ticaret Entegrasyonu</SectionBadge>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
|
||||
Kendi Sitenize Şase Çözme Gücü Katın
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-muted-foreground">
|
||||
Müşterileriniz yanlış parça sipariş edip iade mi açıyor?
|
||||
Sase.tr entegrasyonu ile VIN bazlı filtreleme ekleyin —
|
||||
iadeler düşsün, dönüşüm artsın.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 overflow-hidden rounded-2xl bg-surface p-6 sm:p-8 lg:p-12">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-center lg:gap-12">
|
||||
{/* Left — Video */}
|
||||
<div className="flex-1">
|
||||
<BrowserFrame title="otoyedekparca.co — Sase.tr Entegrasyonu">
|
||||
<Player
|
||||
component={EcommerceDemo}
|
||||
compositionWidth={960}
|
||||
compositionHeight={540}
|
||||
durationInFrames={420}
|
||||
fps={30}
|
||||
loop
|
||||
autoPlay
|
||||
style={{ width: "100%" }}
|
||||
inputProps={{ isDark }}
|
||||
/>
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
|
||||
{/* Right — Text + Bullets + CTA */}
|
||||
<div className="flex-1 space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-3 py-1.5 text-sm text-muted-foreground">
|
||||
<Puzzle className="size-4" />
|
||||
Kolay Entegrasyon
|
||||
</div>
|
||||
<h3 className="font-[family-name:var(--font-display)] text-2xl font-bold sm:text-3xl">
|
||||
Birkaç Satır Kod, Büyük Fark
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
E-ticaret sitenize Sase.tr'nin VIN çözme motorunu entegre edin.
|
||||
Müşterileriniz şase numarasını girsin — sadece araçlarına uygun
|
||||
parçalar listelensin.
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
"API & Widget — Birkaç satır kodla sitenize entegre edin",
|
||||
"VIN Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
|
||||
"İade Oranını Düşürün — Doğru parça, ilk seferde",
|
||||
"White-Label — Widget sitenizin tasarımına uyum sağlar",
|
||||
].map((b) => (
|
||||
<li
|
||||
key={b}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link to="/contact">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2 rounded-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
İletişime Geçin
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ value: "%42", label: "Daha Az İade" },
|
||||
{ value: "%35", label: "Daha Yüksek Dönüşüm" },
|
||||
{ value: "<30dk", label: "Entegrasyon Süresi" },
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-2xl border border-border bg-surface p-6 text-center"
|
||||
>
|
||||
<p className="font-[family-name:var(--font-display)] text-3xl font-bold text-emerald-500 sm:text-4xl">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 9.6. TESTIMONIALS ────────────────────────────────────── */}
|
||||
<section className="bg-surface-alt px-4 py-24 sm:px-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<SectionBadge>Müşteri Yorumları</SectionBadge>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
|
||||
İşletme Sahipleri Ne Diyor?
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{TESTIMONIALS.map((t) => (
|
||||
<div key={t.name} className="rounded-2xl border border-border bg-surface p-6">
|
||||
<div className="flex gap-0.5">
|
||||
{Array.from({ length: t.rating }).map((_, i) => (
|
||||
<span key={i} className="text-amber-400">⭐</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-relaxed text-muted-foreground">
|
||||
“{t.text}”
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{t.initials}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{t.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 10. PRICING ─────────────────────────────────────────────── */}
|
||||
<section className="px-4 py-24 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
@@ -972,7 +1158,7 @@ function HomePage() {
|
||||
Günde 7 TL'den Başlayan Fiyatlar
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-xl text-muted-foreground">
|
||||
Tek bir yanlış parça iadesinin maliyetinden daha az. Tüm fiyatlar KDV dahildir.
|
||||
Tek bir yanlış parça iadesinin maliyetinden daha az.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1077,7 +1263,7 @@ function HomePage() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-[family-name:var(--font-display)] text-xl font-bold sm:text-2xl">
|
||||
Meslektaşlarınızı Davet Edin
|
||||
Arkadaşını Davet Et
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
3 davet = +7 gün ücretsiz, 5 davet = +1 ay ücretsiz
|
||||
@@ -1189,19 +1375,19 @@ function HomePage() {
|
||||
<h4 className="text-sm font-semibold text-muted-foreground">Şirket</h4>
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
<Link to="/about" className="transition hover:text-foreground">
|
||||
Hakkımızda
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
<Link to="/blog" className="transition hover:text-foreground">
|
||||
Blog
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
<Link to="/contact" className="transition hover:text-foreground">
|
||||
İletişim
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1211,19 +1397,19 @@ function HomePage() {
|
||||
<h4 className="text-sm font-semibold text-muted-foreground">Yasal</h4>
|
||||
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
<Link to="/privacy" className="transition hover:text-foreground">
|
||||
Gizlilik Politikası
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
<Link to="/terms" className="transition hover:text-foreground">
|
||||
Kullanım Koşulları
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
<Link to="/kvkk" className="transition hover:text-foreground">
|
||||
KVKK
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1232,9 +1418,15 @@ function HomePage() {
|
||||
<Separator className="bg-border" />
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-4 py-6 sm:flex-row">
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
© {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
© {new Date().getFullYear()} Sase.tr. Tüm hakları saklıdır.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground/70">
|
||||
<span className="size-2 rounded-full bg-emerald-500" />
|
||||
Tüm servisler aktif
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-muted-foreground/70">
|
||||
{/* Social links - minimal */}
|
||||
<a href="#" className="transition hover:text-foreground">
|
||||
|
||||
203
apps/web/src/routes/kvkk.tsx
Normal file
203
apps/web/src/routes/kvkk.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
|
||||
export const Route = createFileRoute("/kvkk")({
|
||||
component: KvkkPage,
|
||||
});
|
||||
|
||||
function KvkkPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost">Giriş Yap</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button>Kayıt Ol</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">
|
||||
KVKK Aydınlatma Metni
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
6698 Sayılı Kişisel Verilerin Korunması Kanunu Kapsamında
|
||||
Aydınlatma Metni
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
1. Veri Sorumlusu
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
6698 sayılı Kişisel Verilerin Korunması Kanunu ("KVKK")
|
||||
uyarınca, kişisel verileriniz veri sorumlusu sıfatıyla
|
||||
Sase.tr tarafından aşağıda açıklanan kapsamda işlenmektedir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
2. İşlenen Kişisel Veriler
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Kimlik Bilgileri:</strong> Ad, soyad
|
||||
</li>
|
||||
<li>
|
||||
<strong>İletişim Bilgileri:</strong> E-posta adresi,
|
||||
telefon numarası (isteğe bağlı)
|
||||
</li>
|
||||
<li>
|
||||
<strong>İşlem Güvenliği:</strong> IP adresi, oturum
|
||||
bilgileri, log kayıtları
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kullanım Verileri:</strong> Arama geçmişi,
|
||||
platform kullanım istatistikleri
|
||||
</li>
|
||||
<li>
|
||||
<strong>Finansal Bilgiler:</strong> Fatura bilgileri,
|
||||
abonelik durumu
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
3. Kişisel Verilerin İşlenme Amaçları
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
Üyelik işlemlerinin gerçekleştirilmesi ve hesap yönetimi
|
||||
</li>
|
||||
<li>
|
||||
Platform hizmetlerinin sunulması ve iyileştirilmesi
|
||||
</li>
|
||||
<li>
|
||||
Ödeme ve faturalama işlemlerinin yürütülmesi
|
||||
</li>
|
||||
<li>
|
||||
Müşteri destek taleplerinin karşılanması
|
||||
</li>
|
||||
<li>
|
||||
Yasal yükümlülüklerin yerine getirilmesi (5651 sayılı
|
||||
Kanun kapsamında log tutma yükümlülüğü dahil)
|
||||
</li>
|
||||
<li>
|
||||
İstatistiksel analiz ve hizmet geliştirme
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
4. Hukuki Sebepler
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, KVKK'nın 5. maddesinde belirtilen
|
||||
aşağıdaki hukuki sebeplere dayanılarak işlenmektedir:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-6">
|
||||
<li>Sözleşmenin kurulması ve ifası</li>
|
||||
<li>Kanunlarda açıkça öngörülmesi</li>
|
||||
<li>Veri sorumlusunun meşru menfaati</li>
|
||||
<li>Açık rıza (gerekli hallerde)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
5. Verilerin Aktarımı
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, hizmetin sunulması amacıyla yurt
|
||||
içindeki iş ortaklarımız (hosting, ödeme altyapısı) ve
|
||||
yasal zorunluluk halinde yetkili kamu kurum ve kuruluşlarıyla
|
||||
paylaşılabilir. Yurt dışına veri aktarımı, KVKK'nın 9.
|
||||
maddesi kapsamındaki güvencelere uygun olarak
|
||||
gerçekleştirilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
6. Veri Saklama Süresi
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, işlenme amaçlarının gerektirdiği süre
|
||||
boyunca ve yasal saklama yükümlülükleri kapsamında muhafaza
|
||||
edilir. Süre sona erdiğinde veriler silinir, yok edilir
|
||||
veya anonim hale getirilir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
7. Haklarınız
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
KVKK'nın 11. maddesi uyarınca aşağıdaki haklara
|
||||
sahipsiniz:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-6">
|
||||
<li>Kişisel verilerinizin işlenip işlenmediğini öğrenme</li>
|
||||
<li>İşlenmişse buna ilişkin bilgi talep etme</li>
|
||||
<li>
|
||||
İşlenme amacını ve amacına uygun kullanılıp
|
||||
kullanılmadığını öğrenme
|
||||
</li>
|
||||
<li>
|
||||
Yurt içinde veya yurt dışında aktarıldığı üçüncü
|
||||
kişileri bilme
|
||||
</li>
|
||||
<li>
|
||||
Eksik veya yanlış işlenmişse düzeltilmesini isteme
|
||||
</li>
|
||||
<li>
|
||||
KVKK'nın 7. maddesindeki şartlar çerçevesinde silinmesini
|
||||
veya yok edilmesini isteme
|
||||
</li>
|
||||
<li>
|
||||
İşlenen verilerin münhasıran otomatik sistemler
|
||||
vasıtasıyla analiz edilmesi suretiyle aleyhinize bir
|
||||
sonucun ortaya çıkmasına itiraz etme
|
||||
</li>
|
||||
<li>
|
||||
Kanuna aykırı işlenmesi sebebiyle zarara uğramanız
|
||||
halinde zararın giderilmesini talep etme
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
8. Başvuru Yöntemi
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Yukarıda belirtilen haklarınızı kullanmak için{" "}
|
||||
<a
|
||||
href="mailto:info@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
info@sase.tr
|
||||
</a>{" "}
|
||||
adresine kimliğinizi tespit edici belgelerle birlikte
|
||||
yazılı olarak başvurabilirsiniz. Başvurular en geç 30 gün
|
||||
içinde sonuçlandırılır.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function PricingPage() {
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Fiyatlandırma</h1>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
İhtiyacınıza uygun planı seçin. Tüm fiyatlar KDV dahildir.
|
||||
İhtiyacınıza uygun planı seçin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
150
apps/web/src/routes/privacy.tsx
Normal file
150
apps/web/src/routes/privacy.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
|
||||
export const Route = createFileRoute("/privacy")({
|
||||
component: PrivacyPage,
|
||||
});
|
||||
|
||||
function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost">Giriş Yap</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button>Kayıt Ol</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Gizlilik Politikası</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Son güncelleme: 15 Şubat 2026
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
1. Genel Bakış
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Sase.tr ("Platform") olarak kullanıcılarımızın gizliliğine
|
||||
önem veriyoruz. Bu politika, kişisel verilerinizin nasıl
|
||||
toplandığını, işlendiğini ve korunduğunu açıklar.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
2. Toplanan Veriler
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Hesap Bilgileri:</strong> Ad, e-posta adresi,
|
||||
telefon numarası (isteğe bağlı).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kullanım Verileri:</strong> Arama geçmişi, VIN
|
||||
sorguları, sayfa görüntüleme istatistikleri.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Teknik Veriler:</strong> IP adresi, tarayıcı türü,
|
||||
cihaz bilgisi, çerez verileri.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ödeme Bilgileri:</strong> Ödeme işlemleri üçüncü
|
||||
parti ödeme sağlayıcıları aracılığıyla gerçekleştirilir.
|
||||
Kredi kartı bilgileri tarafımızca saklanmaz.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
3. Verilerin Kullanım Amacı
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>Hizmetin sunulması ve iyileştirilmesi</li>
|
||||
<li>Kullanıcı hesaplarının yönetimi</li>
|
||||
<li>Müşteri desteği sağlanması</li>
|
||||
<li>Yasal yükümlülüklerin yerine getirilmesi</li>
|
||||
<li>İstatistiksel analiz ve raporlama</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
4. Veri Paylaşımı
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Kişisel verileriniz, yasal zorunluluklar dışında üçüncü
|
||||
taraflarla paylaşılmaz. Hizmet sağlayıcılarımız (hosting,
|
||||
ödeme altyapısı) yalnızca hizmetin işletilmesi için gerekli
|
||||
olan verilere erişir ve gizlilik sözleşmeleri ile bağlıdır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
5. Çerezler
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Platform, oturum yönetimi ve kullanıcı deneyimini
|
||||
iyileştirmek amacıyla çerezler kullanır. Zorunlu çerezler
|
||||
hizmetin çalışması için gereklidir. Analitik çerezler
|
||||
kullanıcı tercihine bağlıdır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
6. Veri Güvenliği
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Verileriniz SSL/TLS şifrelemesi ile korunur. Sunucularımız
|
||||
güvenli veri merkezlerinde barındırılır ve düzenli güvenlik
|
||||
denetimleri yapılır.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
7. Haklarınız
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
KVKK kapsamında kişisel verilerinize erişim, düzeltme,
|
||||
silme ve işlemeye itiraz etme haklarına sahipsiniz.
|
||||
Talepleriniz için{" "}
|
||||
<a
|
||||
href="mailto:info@sase.tr"
|
||||
className="text-primary underline"
|
||||
>
|
||||
info@sase.tr
|
||||
</a>{" "}
|
||||
adresine başvurabilirsiniz.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
8. Değişiklikler
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Bu politika zaman zaman güncellenebilir. Önemli
|
||||
değişiklikler e-posta veya platform içi bildirim yoluyla
|
||||
duyurulur.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
apps/web/src/routes/terms.tsx
Normal file
150
apps/web/src/routes/terms.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Button } from "@sase/ui";
|
||||
|
||||
export const Route = createFileRoute("/terms")({
|
||||
component: TermsPage,
|
||||
});
|
||||
|
||||
function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Sase.tr
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost">Giriş Yap</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button>Kayıt Ol</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-3xl px-4 py-16">
|
||||
<h1 className="text-4xl font-bold">Kullanım Koşulları</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Son güncelleme: 15 Şubat 2026
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-8 text-muted-foreground leading-relaxed">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
1. Kabul ve Onay
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Sase.tr platformunu ("Platform") kullanarak bu kullanım
|
||||
koşullarını kabul etmiş sayılırsınız. Koşulları kabul
|
||||
etmiyorsanız platformu kullanmayınız.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
2. Hizmet Tanımı
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Platform, şase numarası (VIN) ile araç tanımlama, orijinal
|
||||
yedek parça kataloğuna erişim ve interaktif şema görüntüleme
|
||||
hizmetleri sunar. Hizmetler abonelik modeli ile sunulur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
3. Hesap Oluşturma
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>Kayıt sırasında doğru ve güncel bilgi vermekle yükümlüsünüz.</li>
|
||||
<li>Hesap bilgilerinizin güvenliğinden siz sorumlusunuz.</li>
|
||||
<li>Hesabınızı üçüncü kişilerle paylaşamazsınız.</li>
|
||||
<li>18 yaşından küçükler platformu kullanamaz.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
4. Abonelik ve Ödeme
|
||||
</h2>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
Abonelikler aylık veya yıllık olarak faturalandırılır.
|
||||
</li>
|
||||
<li>
|
||||
Yıllık aboneliklerde indirimli fiyat uygulanır.
|
||||
</li>
|
||||
<li>
|
||||
Abonelik, dönem sonunda otomatik olarak yenilenir.
|
||||
</li>
|
||||
<li>
|
||||
İptal işlemi mevcut dönemin sonunda geçerli olur;
|
||||
kalan süre için iade yapılmaz.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
5. Kullanım Kuralları
|
||||
</h2>
|
||||
<p className="mt-3">Aşağıdaki eylemler yasaktır:</p>
|
||||
<ul className="mt-2 list-disc space-y-2 pl-6">
|
||||
<li>Platformdaki verilerin toplu olarak çekilmesi (scraping)</li>
|
||||
<li>API'nin izinsiz kullanımı veya kötüye kullanımı</li>
|
||||
<li>Diğer kullanıcıların hizmetini engelleyecek faaliyetler</li>
|
||||
<li>Platform verilerinin ticari amaçla yeniden dağıtılması</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
6. Fikri Mülkiyet
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Platform üzerindeki tüm içerik, tasarım, yazılım ve
|
||||
veritabanı Sase.tr'ye aittir. Kullanıcılar yalnızca
|
||||
kişisel kullanım amacıyla erişim hakkına sahiptir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
7. Sorumluluk Sınırı
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Platform, sunulan bilgilerin doğruluğu için azami özeni
|
||||
gösterir ancak verilerin eksiksiz veya hatasız olduğunu
|
||||
garanti etmez. Yedek parça alım kararlarında son sorumluluk
|
||||
kullanıcıya aittir.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
8. Hizmet Değişiklikleri
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Sase.tr, hizmet içeriğini, fiyatlandırmayı ve bu koşulları
|
||||
önceden bildirimde bulunarak değiştirme hakkını saklı tutar.
|
||||
Önemli değişiklikler en az 30 gün önce duyurulur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
9. Uyuşmazlık Çözümü
|
||||
</h2>
|
||||
<p className="mt-3">
|
||||
Bu koşullar Türkiye Cumhuriyeti kanunlarına tabidir.
|
||||
Uyuşmazlıklarda İstanbul mahkemeleri ve icra daireleri
|
||||
yetkilidir.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 373 KiB |
Reference in New Issue
Block a user