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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user