feat(mobile): integrate Instagram/Canva style interactive QR Canvas Studio with touch drag, rotate, badge boxes, shadows and dynamic layers

This commit is contained in:
AyrisAI
2026-08-21 01:24:17 +03:00
parent d00b45ce72
commit ae2386eacf
5 changed files with 952 additions and 474 deletions
@@ -0,0 +1,573 @@
import React, { useState } from "react";
import {
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { CanvasLayerItem } from "./CanvasLayerItem";
import {
type CanvasTextLayer,
CANVAS_FONT_OPTIONS,
CANVAS_COLOR_PALETTE,
INSTAGRAM_BADGE_STYLES,
getDefaultLayersForTemplate,
} from "./types";
interface QrCanvasStudioProps {
templateKey: string;
restaurantName: string;
qrBase64?: string;
width: number;
height: number;
renderBackground: () => React.ReactNode;
layers: CanvasTextLayer[];
onLayersChange: React.Dispatch<React.SetStateAction<CanvasTextLayer[]>>;
}
export const QrCanvasStudio: React.FC<QrCanvasStudioProps> = ({
templateKey,
restaurantName,
width,
height,
renderBackground,
layers,
onLayersChange,
}) => {
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(layers[0]?.id || null);
const selectedLayer = layers.find((l) => l.id === selectedLayerId);
// Pozisyon Güncelleme
const handleUpdatePosition = (id: string, x: number, y: number) => {
onLayersChange((prev) =>
prev.map((l) => (l.id === id ? { ...l, x, y } : l))
);
};
// Yeni Katman Ekle
const handleAddTextLayer = () => {
const newLayer: CanvasTextLayer = {
id: `layer_${Date.now()}`,
text: "Yeni Metin",
x: Math.round(width / 2 - 40),
y: Math.round(height / 2 - 20),
rotation: 0,
fontSize: 14,
fontId: "sans",
color: "#FFFFFF",
bgBoxColor: "rgba(18, 18, 21, 0.85)",
fontWeight: "800",
};
onLayersChange((prev) => [...prev, newLayer]);
setSelectedLayerId(newLayer.id);
};
// Katman Sil
const handleDeleteLayer = (id: string) => {
onLayersChange((prev) => prev.filter((l) => l.id !== id));
if (selectedLayerId === id) {
setSelectedLayerId(null);
}
};
// Katman Kopyala
const handleDuplicateLayer = (id: string) => {
const target = layers.find((l) => l.id === id);
if (!target) return;
const duplicated: CanvasTextLayer = {
...target,
id: `layer_${Date.now()}`,
x: Math.min(width - 50, target.x + 15),
y: Math.min(height - 30, target.y + 15),
};
onLayersChange((prev) => [...prev, duplicated]);
setSelectedLayerId(duplicated.id);
};
// Şablonu Sıfırla
const handleResetLayers = () => {
const defaults = getDefaultLayersForTemplate(templateKey, restaurantName);
onLayersChange(defaults);
setSelectedLayerId(defaults[0]?.id || null);
};
return (
<View style={{ width: "100%", alignItems: "center" }}>
{/* 1. İNTERAKTİF TUVAL ALANI */}
<View
style={{
width,
height,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "#0C0C0E",
position: "relative",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
elevation: 8,
}}
>
{/* Arka Plan Şablonu & Sabit QR */}
<View style={{ position: "absolute", top: 0, left: 0, width, height }}>
{renderBackground()}
</View>
{/* Serbest Sürüklenebilir Katmanlar */}
{layers.map((layer) => (
<CanvasLayerItem
key={layer.id}
layer={layer}
isSelected={selectedLayerId === layer.id}
onSelect={setSelectedLayerId}
onUpdatePosition={handleUpdatePosition}
onDelete={handleDeleteLayer}
canvasWidth={width}
canvasHeight={height}
/>
))}
</View>
{/* 2. STÜDYO EYLEM BUTONLARI */}
<View
style={{
width: "100%",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginVertical: 14,
gap: 8,
}}
>
{/* Yeni Metin Ekle */}
<Pressable
onPress={handleAddTextLayer}
style={({ pressed }) => ({
flex: 1,
backgroundColor: "#D4AF37",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="add-circle" size={18} color="#0C0C0E" />
<Text style={{ color: "#0C0C0E", fontSize: 13, fontWeight: "800" }}>
+ Metin Ekle
</Text>
</Pressable>
{/* Seçiliyi Kopyala */}
{selectedLayer && (
<Pressable
onPress={() => handleDuplicateLayer(selectedLayer.id)}
style={({ pressed }) => ({
backgroundColor: "#242429",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="copy-outline" size={16} color="#FFFFFF" />
<Text style={{ color: "#FFFFFF", fontSize: 12, fontWeight: "700" }}>
Çoğalt
</Text>
</Pressable>
)}
{/* Sıfırla */}
<Pressable
onPress={handleResetLayers}
style={({ pressed }) => ({
backgroundColor: "#242429",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 12,
flexDirection: "row",
alignItems: "center",
gap: 6,
opacity: pressed ? 0.85 : 1,
})}
>
<Ionicons name="refresh-outline" size={16} color="#A1A1AA" />
<Text style={{ color: "#A1A1AA", fontSize: 12, fontWeight: "600" }}>
Sıfırla
</Text>
</Pressable>
</View>
{/* 3. SEÇİLİ METİN INSTAGRAM TARZI DÜZENLEYİCİ */}
{selectedLayer ? (
<View
style={{
width: "100%",
backgroundColor: "#18181B",
borderRadius: 20,
borderWidth: 1.5,
borderColor: "#D4AF37",
padding: 16,
marginBottom: 20,
gap: 14,
shadowColor: "#D4AF37",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 10,
elevation: 4,
}}
>
{/* Başlık & Kapat */}
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: "#D4AF37",
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name="sparkles" size={15} color="#0C0C0E" />
</View>
<Text style={{ color: "#FFFFFF", fontSize: 15, fontWeight: "800" }}>
Metin & Stil Stüdyosu
</Text>
</View>
<Pressable
onPress={() => setSelectedLayerId(null)}
style={{
backgroundColor: "rgba(255,255,255,0.08)",
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
}}
>
<Text style={{ color: "#A1A1AA", fontSize: 11, fontWeight: "600" }}>Seçimi Kaldır</Text>
</Pressable>
</View>
{/* Metin İçeriği (Enter Destekli) */}
<View style={{ gap: 4 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
METİN İÇERİĞİ (ENTER İLE YENİ SATIR):
</Text>
<TextInput
value={selectedLayer.text}
onChangeText={(t) =>
onLayersChange((prev) =>
prev.map((l) => (l.id === selectedLayer.id ? { ...l, text: t } : l))
)
}
placeholder="Metninizi yazın..."
placeholderTextColor="#71717A"
multiline
style={{
backgroundColor: "#121215",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.12)",
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
color: "#FFFFFF",
fontSize: 14,
minHeight: 46,
}}
/>
</View>
{/* DÖNDÜRME & EĞME AÇISI (ROTATION) */}
<View style={{ gap: 6, backgroundColor: "#121215", padding: 12, borderRadius: 12 }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
EĞİM & DÖNDÜRME AÇISI (ROTATION)
</Text>
<Text style={{ color: "#E5C378", fontSize: 11, fontWeight: "800" }}>
{selectedLayer.rotation || 0}°
</Text>
</View>
<View style={{ flexDirection: "row", gap: 6 }}>
{[-15, -6, 0, 6, 15].map((deg) => {
const isActive = selectedLayer.rotation === deg;
return (
<Pressable
key={deg}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, rotation: deg } : l
)
)
}
style={{
flex: 1,
backgroundColor: isActive ? "#D4AF37" : "#242429",
paddingVertical: 8,
borderRadius: 8,
alignItems: "center",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 11,
fontWeight: isActive ? "800" : "600",
}}
>
{deg > 0 ? `+${deg}°` : `${deg}°`}
</Text>
</Pressable>
);
})}
</View>
</View>
{/* INSTAGRAM TARZI VURGU / KUTU STİLİ */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
INSTAGRAM KUTUCUK STİLİ (BADGE):
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{INSTAGRAM_BADGE_STYLES.map((b) => {
const isActive = selectedLayer.bgBoxColor === b.bgBoxColor;
return (
<Pressable
key={b.id}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, bgBoxColor: b.bgBoxColor } : l
)
)
}
style={{
backgroundColor: isActive ? "#D4AF37" : "#242429",
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
borderWidth: 1.5,
borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.06)",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 11.5,
fontWeight: isActive ? "800" : "600",
}}
>
{b.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* YAZI RENGİ PALETİ */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
YAZI RENGİ:
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
{CANVAS_COLOR_PALETTE.map((c) => {
const isActive = selectedLayer.color === c.color;
return (
<Pressable
key={c.id}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, color: c.color } : l
)
)
}
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: c.color,
borderWidth: isActive ? 3 : 1.5,
borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.3)",
alignItems: "center",
justifyContent: "center",
}}
>
{isActive && (
<Ionicons
name="checkmark"
size={16}
color={c.color === "#FFFFFF" || c.color === "#F6EFE3" ? "#0C0C0E" : "#FFFFFF"}
/>
)}
</Pressable>
);
})}
</ScrollView>
</View>
{/* YAZI TİPİ (FONT) */}
<View style={{ gap: 6 }}>
<Text style={{ color: "#D4AF37", fontSize: 11, fontWeight: "700" }}>
YAZI TİPİ (TÜRKÇE UYUMLU):
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 6 }}>
{CANVAS_FONT_OPTIONS.map((f) => {
const isActive = selectedLayer.fontId === f.id;
return (
<Pressable
key={f.id}
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, fontId: f.id } : l
)
)
}
style={{
backgroundColor: isActive ? "#D4AF37" : "#242429",
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 10,
borderWidth: 1.5,
borderColor: isActive ? "#D4AF37" : "rgba(255,255,255,0.06)",
}}
>
<Text
style={{
color: isActive ? "#0C0C0E" : "#FFFFFF",
fontSize: 11.5,
fontWeight: isActive ? "800" : "600",
}}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
{/* BOYUT & GÖLGE AYARI */}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "#121215",
padding: 10,
borderRadius: 12,
}}
>
{/* Boyut */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text style={{ color: "#D4AF37", fontSize: 12, fontWeight: "700" }}>
Boyut:
</Text>
<Pressable
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id
? { ...l, fontSize: Math.max(8, l.fontSize - 1) }
: l
)
)
}
style={{
backgroundColor: "#242429",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
}}
>
<Ionicons name="remove" size={15} color="#FFFFFF" />
</Pressable>
<Text style={{ color: "#D4AF37", fontSize: 15, fontWeight: "800", minWidth: 32, textAlign: "center" }}>
{selectedLayer.fontSize}px
</Text>
<Pressable
onPress={() =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id
? { ...l, fontSize: Math.min(60, l.fontSize + 1) }
: l
)
)
}
style={{
backgroundColor: "#242429",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
}}
>
<Ionicons name="add" size={15} color="#FFFFFF" />
</Pressable>
</View>
{/* Gölge Anahtarı */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text style={{ color: "#A1A1AA", fontSize: 11, fontWeight: "700" }}>
Gölge
</Text>
<Switch
value={selectedLayer.hasShadow || false}
onValueChange={(v) =>
onLayersChange((prev) =>
prev.map((l) =>
l.id === selectedLayer.id ? { ...l, hasShadow: v } : l
)
)
}
trackColor={{ false: "#27272A", true: "#D4AF37" }}
thumbColor="#FFFFFF"
ios_backgroundColor="#27272A"
/>
</View>
</View>
</View>
) : (
<View
style={{
width: "100%",
backgroundColor: "#18181B",
borderRadius: 16,
padding: 14,
marginBottom: 20,
alignItems: "center",
flexDirection: "row",
justifyContent: "center",
gap: 8,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.06)",
}}
>
<Ionicons name="hand-left-outline" size={18} color="#D4AF37" />
<Text style={{ color: "#A1A1AA", fontSize: 12.5, fontWeight: "600" }}>
Stant üzerindeki herhangi bir metne dokunarak sürükleyin veya düzenleyin.
</Text>
</View>
)}
</View>
);
};