@expokit/cli 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/bin/expo-kit.js +1266 -0
- package/bin/lib.mjs +343 -0
- package/bin/mcp.mjs +345 -0
- package/package.json +49 -4
- package/registry/_docs/accordion.json +115 -0
- package/registry/_docs/action-sheet.json +138 -0
- package/registry/_docs/alert.json +155 -0
- package/registry/_docs/avatar.json +227 -0
- package/registry/_docs/badge.json +148 -0
- package/registry/_docs/bottom-nav.json +143 -0
- package/registry/_docs/bottom-sheet.json +186 -0
- package/registry/_docs/bullet.json +27 -0
- package/registry/_docs/button-3d.json +138 -0
- package/registry/_docs/button.json +131 -0
- package/registry/_docs/calendar.json +198 -0
- package/registry/_docs/card-stack.json +124 -0
- package/registry/_docs/card.json +131 -0
- package/registry/_docs/carousel.json +122 -0
- package/registry/_docs/chat.json +184 -0
- package/registry/_docs/checkbox.json +109 -0
- package/registry/_docs/checkout.json +105 -0
- package/registry/_docs/chip-group.json +132 -0
- package/registry/_docs/color-swatch.json +82 -0
- package/registry/_docs/confetti.json +102 -0
- package/registry/_docs/coupon.json +192 -0
- package/registry/_docs/cube-pager.json +100 -0
- package/registry/_docs/dashboard.json +123 -0
- package/registry/_docs/date-picker.json +269 -0
- package/registry/_docs/dialog.json +186 -0
- package/registry/_docs/drawer.json +199 -0
- package/registry/_docs/fab.json +144 -0
- package/registry/_docs/flip-card.json +127 -0
- package/registry/_docs/floating-input.json +211 -0
- package/registry/_docs/gift-card.json +124 -0
- package/registry/_docs/glass-button.json +142 -0
- package/registry/_docs/glass-nav-bar.json +102 -0
- package/registry/_docs/glass-search-bar.json +114 -0
- package/registry/_docs/glass-segmented.json +94 -0
- package/registry/_docs/glass-sheet.json +136 -0
- package/registry/_docs/glass-tab-bar.json +100 -0
- package/registry/_docs/glass.json +94 -0
- package/registry/_docs/inbox.json +75 -0
- package/registry/_docs/isometric-bar.json +75 -0
- package/registry/_docs/kit.json +14 -0
- package/registry/_docs/kpi-card.json +14 -0
- package/registry/_docs/like-button.json +92 -0
- package/registry/_docs/liquidglass-tab.json +30 -0
- package/registry/_docs/list-item.json +216 -0
- package/registry/_docs/loyalty-card.json +124 -0
- package/registry/_docs/morph-button.json +128 -0
- package/registry/_docs/number-ticker.json +112 -0
- package/registry/_docs/offers.json +81 -0
- package/registry/_docs/onboarding.json +69 -0
- package/registry/_docs/otp-input.json +158 -0
- package/registry/_docs/pagination-dots.json +108 -0
- package/registry/_docs/parallax-layers.json +104 -0
- package/registry/_docs/parallax-scroll.json +210 -0
- package/registry/_docs/password-input.json +112 -0
- package/registry/_docs/payment-card.json +169 -0
- package/registry/_docs/pixel-bar.json +69 -0
- package/registry/_docs/polar-area.json +81 -0
- package/registry/_docs/profile.json +87 -0
- package/registry/_docs/progress-steps.json +116 -0
- package/registry/_docs/progress.json +152 -0
- package/registry/_docs/radio-group.json +112 -0
- package/registry/_docs/rating.json +114 -0
- package/registry/_docs/ring-carousel.json +122 -0
- package/registry/_docs/scratch-card.json +110 -0
- package/registry/_docs/search-bar.json +160 -0
- package/registry/_docs/search-results.json +123 -0
- package/registry/_docs/segmented-control.json +123 -0
- package/registry/_docs/select.json +277 -0
- package/registry/_docs/settings.json +75 -0
- package/registry/_docs/shimmer-button.json +98 -0
- package/registry/_docs/sign-in.json +201 -0
- package/registry/_docs/skeleton.json +162 -0
- package/registry/_docs/slide-to-confirm.json +108 -0
- package/registry/_docs/slider.json +177 -0
- package/registry/_docs/spin-wheel.json +138 -0
- package/registry/_docs/spinner.json +121 -0
- package/registry/_docs/stateful-button.json +184 -0
- package/registry/_docs/stepper.json +184 -0
- package/registry/_docs/swipe-deck.json +118 -0
- package/registry/_docs/swipeable-row.json +95 -0
- package/registry/_docs/switch.json +124 -0
- package/registry/_docs/tabs.json +129 -0
- package/registry/_docs/tag-input.json +148 -0
- package/registry/_docs/text-animation.json +141 -0
- package/registry/_docs/textarea.json +191 -0
- package/registry/_docs/ticket-card.json +194 -0
- package/registry/_docs/ticket-shape.json +14 -0
- package/registry/_docs/tilt-card.json +157 -0
- package/registry/_docs/time-picker.json +269 -0
- package/registry/_docs/timeline-chart.json +33 -0
- package/registry/_docs/timeline.json +172 -0
- package/registry/_docs/toast.json +185 -0
- package/registry/_docs/treemap.json +33 -0
- package/registry/_docs/waterfall.json +39 -0
- package/registry/_docs/wheel-list.json +122 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
- package/registry/accordion/files/src/lib/kit.tsx +219 -0
- package/registry/accordion.json +17 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
- package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/action-sheet.json +18 -0
- package/registry/alert/files/src/components/ui/alert.tsx +654 -0
- package/registry/alert/files/src/components/ui/button.tsx +274 -0
- package/registry/alert/files/src/lib/kit.tsx +219 -0
- package/registry/alert.json +20 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
- package/registry/avatar/files/src/lib/kit.tsx +219 -0
- package/registry/avatar.json +18 -0
- package/registry/badge/files/src/components/ui/badge.tsx +626 -0
- package/registry/badge/files/src/lib/kit.tsx +219 -0
- package/registry/badge.json +17 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
- package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-nav.json +18 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
- package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-sheet.json +17 -0
- package/registry/bullet.json +14 -0
- package/registry/button/files/src/components/ui/button.tsx +274 -0
- package/registry/button/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
- package/registry/button-3d/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d.json +18 -0
- package/registry/button.json +18 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
- package/registry/calendar/files/src/lib/kit.tsx +219 -0
- package/registry/calendar.json +17 -0
- package/registry/card/files/src/components/ui/card.tsx +300 -0
- package/registry/card/files/src/lib/kit.tsx +219 -0
- package/registry/card-stack.json +15 -0
- package/registry/card.json +17 -0
- package/registry/carousel.json +15 -0
- package/registry/chat.json +18 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
- package/registry/checkbox/files/src/lib/kit.tsx +219 -0
- package/registry/checkbox.json +17 -0
- package/registry/checkout.json +26 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
- package/registry/chip-group/files/src/lib/kit.tsx +219 -0
- package/registry/chip-group.json +17 -0
- package/registry/color-swatch.json +17 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
- package/registry/confetti/files/src/lib/kit.tsx +219 -0
- package/registry/confetti.json +15 -0
- package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
- package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/coupon/files/src/lib/kit.tsx +219 -0
- package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/coupon.json +23 -0
- package/registry/cube-pager.json +15 -0
- package/registry/dashboard.json +32 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
- package/registry/date-picker/files/src/lib/kit.tsx +219 -0
- package/registry/date-picker.json +19 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
- package/registry/dialog/files/src/lib/kit.tsx +219 -0
- package/registry/dialog.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
- package/registry/drawer/files/src/lib/kit.tsx +219 -0
- package/registry/drawer.json +18 -0
- package/registry/fab.json +18 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/flip-card/files/src/lib/kit.tsx +219 -0
- package/registry/flip-card.json +15 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/floating-input/files/src/lib/kit.tsx +219 -0
- package/registry/floating-input.json +17 -0
- package/registry/gift-card.json +21 -0
- package/registry/glass-button.json +22 -0
- package/registry/glass-nav-bar.json +21 -0
- package/registry/glass-search-bar.json +21 -0
- package/registry/glass-segmented.json +22 -0
- package/registry/glass-sheet.json +22 -0
- package/registry/glass-tab-bar.json +22 -0
- package/registry/glass.json +19 -0
- package/registry/inbox.json +30 -0
- package/registry/isometric-bar.json +14 -0
- package/registry/kit/files/src/lib/kit.tsx +219 -0
- package/registry/kit.json +11 -0
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
- package/registry/kpi-card.json +14 -0
- package/registry/like-button.json +18 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
- package/registry/liquidglass-tab.json +23 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
- package/registry/list-item/files/src/lib/kit.tsx +219 -0
- package/registry/list-item.json +17 -0
- package/registry/loyalty-card.json +23 -0
- package/registry/morph-button.json +18 -0
- package/registry/number-ticker.json +15 -0
- package/registry/offers.json +24 -0
- package/registry/onboarding.json +17 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
- package/registry/otp-input/files/src/lib/kit.tsx +219 -0
- package/registry/otp-input.json +17 -0
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
- package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
- package/registry/pagination-dots.json +15 -0
- package/registry/parallax-layers.json +15 -0
- package/registry/parallax-scroll.json +17 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
- package/registry/password-input/files/src/lib/kit.tsx +219 -0
- package/registry/password-input.json +19 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/payment-card/files/src/lib/kit.tsx +219 -0
- package/registry/payment-card.json +21 -0
- package/registry/pixel-bar.json +14 -0
- package/registry/polar-area.json +14 -0
- package/registry/profile.json +17 -0
- package/registry/progress/files/src/components/ui/progress.tsx +883 -0
- package/registry/progress/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
- package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps.json +18 -0
- package/registry/progress.json +17 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
- package/registry/radio-group/files/src/lib/kit.tsx +219 -0
- package/registry/radio-group.json +18 -0
- package/registry/rating.json +18 -0
- package/registry/ring-carousel.json +15 -0
- package/registry/scratch-card.json +18 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
- package/registry/search-bar/files/src/lib/kit.tsx +219 -0
- package/registry/search-bar.json +17 -0
- package/registry/search-results.json +30 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
- package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
- package/registry/segmented-control.json +18 -0
- package/registry/select/files/src/components/ui/select.tsx +1398 -0
- package/registry/select/files/src/lib/kit.tsx +219 -0
- package/registry/select.json +17 -0
- package/registry/settings.json +26 -0
- package/registry/shimmer-button.json +17 -0
- package/registry/sign-in.json +26 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
- package/registry/skeleton/files/src/lib/kit.tsx +219 -0
- package/registry/skeleton.json +17 -0
- package/registry/slide-to-confirm.json +18 -0
- package/registry/slider/files/src/components/ui/slider.tsx +716 -0
- package/registry/slider/files/src/lib/kit.tsx +219 -0
- package/registry/slider.json +17 -0
- package/registry/spin-wheel.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +748 -0
- package/registry/spinner/files/src/lib/kit.tsx +219 -0
- package/registry/spinner.json +17 -0
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
- package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
- package/registry/stateful-button.json +18 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
- package/registry/stepper/files/src/lib/kit.tsx +219 -0
- package/registry/stepper.json +17 -0
- package/registry/swipe-deck.json +15 -0
- package/registry/swipeable-row.json +15 -0
- package/registry/switch/files/src/components/ui/switch.tsx +344 -0
- package/registry/switch/files/src/lib/kit.tsx +219 -0
- package/registry/switch.json +18 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
- package/registry/tabs/files/src/lib/kit.tsx +219 -0
- package/registry/tabs.json +17 -0
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
- package/registry/tag-input/files/src/lib/kit.tsx +219 -0
- package/registry/tag-input.json +17 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
- package/registry/text-animation/files/src/lib/kit.tsx +219 -0
- package/registry/text-animation.json +15 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
- package/registry/textarea/files/src/lib/kit.tsx +219 -0
- package/registry/textarea.json +17 -0
- package/registry/ticket-card.json +24 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/ticket-shape.json +17 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
- package/registry/tilt-card.json +17 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
- package/registry/time-picker/files/src/lib/kit.tsx +219 -0
- package/registry/time-picker.json +17 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
- package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
- package/registry/timeline/files/src/lib/kit.tsx +219 -0
- package/registry/timeline-chart.json +14 -0
- package/registry/timeline.json +20 -0
- package/registry/toast/files/src/components/ui/button.tsx +274 -0
- package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
- package/registry/toast/files/src/lib/kit.tsx +219 -0
- package/registry/toast.json +20 -0
- package/registry/treemap.json +14 -0
- package/registry/waterfall.json +14 -0
- package/registry/wheel-list.json +17 -0
- package/schema/components.schema.json +13 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Animated, Easing, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import Svg, { Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
|
|
4
|
+
|
|
5
|
+
import { FlipCard, useFlipProgress } from '@/components/ui/flip-card';
|
|
6
|
+
import { TiltCard } from '@/components/ui/tilt-card';
|
|
7
|
+
import { alpha, motion, readableOn, useKit } from '@/lib/kit';
|
|
8
|
+
import {
|
|
9
|
+
BarcodeLike,
|
|
10
|
+
TicketShape,
|
|
11
|
+
TicketStamp,
|
|
12
|
+
ticketSafeRects,
|
|
13
|
+
type TicketCorners,
|
|
14
|
+
type TicketEdge,
|
|
15
|
+
type TicketGeometry,
|
|
16
|
+
type TicketPattern,
|
|
17
|
+
type TicketRect,
|
|
18
|
+
type TicketSide,
|
|
19
|
+
} from '@/lib/ticket-shape';
|
|
20
|
+
|
|
21
|
+
export type CouponStatus = 'available' | 'applied' | 'used' | 'expired' | 'locked';
|
|
22
|
+
/** Outline: `ticket` notches, `stamp` perforations, `receipt` torn zigzag ends, `scallop` bumps, `cutout` dashed "cut here". */
|
|
23
|
+
export type CouponVariant = 'ticket' | 'stamp' | 'receipt' | 'scallop' | 'cutout';
|
|
24
|
+
export type CouponThemeName = 'sunset' | 'candy' | 'ocean' | 'lime' | 'gold' | 'fire' | 'neon' | 'ink' | 'paper' | 'holo';
|
|
25
|
+
export type CouponSize = 'sm' | 'md' | 'lg';
|
|
26
|
+
|
|
27
|
+
export interface CouponTheme {
|
|
28
|
+
/** Value panel paint: a colour, or 2+ colours for a gradient. */
|
|
29
|
+
fill: string | readonly string[];
|
|
30
|
+
/** Text on the value panel. */
|
|
31
|
+
ink: string;
|
|
32
|
+
/** Buttons, the code chip and the applied ring (default: the fill's last colour). */
|
|
33
|
+
accent?: string;
|
|
34
|
+
/** Details panel paint (default: the app's card colour). */
|
|
35
|
+
body?: string | readonly string[];
|
|
36
|
+
/** Text on the details panel (default: the app's foreground). */
|
|
37
|
+
bodyInk?: string;
|
|
38
|
+
/** Texture on the value panel. */
|
|
39
|
+
pattern?: TicketPattern;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const couponThemes: Record<CouponThemeName, CouponTheme> = {
|
|
43
|
+
sunset: { fill: ['#ffb000', '#ff6a3d', '#ff2e7e'], ink: '#ffffff', accent: '#ff3d6e', pattern: 'sunburst' },
|
|
44
|
+
candy: { fill: ['#ff6ad5', '#c86bff', '#7b5cff'], ink: '#ffffff', accent: '#a64dff', pattern: 'confetti' },
|
|
45
|
+
ocean: { fill: ['#2af5ff', '#1f8bff', '#3d47ff'], ink: '#ffffff', accent: '#1f6fff', pattern: 'waves' },
|
|
46
|
+
lime: { fill: ['#e6ff4d', '#7cf05a', '#1fc46b'], ink: '#062b16', accent: '#15a057', pattern: 'dots' },
|
|
47
|
+
gold: { fill: ['#fff1b8', '#ffc94a', '#e09a12'], ink: '#3d2500', accent: '#c47f00', pattern: 'stripes' },
|
|
48
|
+
fire: { fill: ['#ffa53d', '#ff3d3d', '#c4002f'], ink: '#ffffff', accent: '#e8243a', pattern: 'sunburst' },
|
|
49
|
+
neon: { fill: ['#120b2e', '#2b1660', '#3a0d58'], ink: '#3dff9a', accent: '#18c96f', pattern: 'grid' },
|
|
50
|
+
ink: { fill: ['#262626', '#050505'], ink: '#ffe14d', accent: '#ffd000', pattern: 'stripes' },
|
|
51
|
+
paper: { fill: '#ffe6bd', ink: '#b3361b', accent: '#c2410c', body: '#fffaf0', bodyInk: '#3b1d10', pattern: 'dots' },
|
|
52
|
+
holo: { fill: ['#ffd1f5', '#d6cfff', '#c4f3ff', '#d4ffe4', '#fff3c2'], ink: '#2b2350', accent: '#6b4dff' },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface CouponLabels {
|
|
56
|
+
copy?: string;
|
|
57
|
+
copied?: string;
|
|
58
|
+
apply?: string;
|
|
59
|
+
applied?: string;
|
|
60
|
+
used?: string;
|
|
61
|
+
expired?: string;
|
|
62
|
+
locked?: string;
|
|
63
|
+
/** Before the countdown (default "Ends in"). */
|
|
64
|
+
endsIn?: string;
|
|
65
|
+
/** The link that turns the coupon over (default "T&C"). */
|
|
66
|
+
terms?: string;
|
|
67
|
+
/** Heading on the back (default "Terms & conditions"). */
|
|
68
|
+
termsTitle?: string;
|
|
69
|
+
/** Hint on the back (default "Tap to turn back"). */
|
|
70
|
+
back?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CouponProps {
|
|
74
|
+
/** The headline: "50%", "₹200", "FREE", "BOGO". */
|
|
75
|
+
value: string;
|
|
76
|
+
/** Under the value: "OFF", "CASHBACK". */
|
|
77
|
+
unit?: string;
|
|
78
|
+
/** Above the value: "FLAT", "UP TO". */
|
|
79
|
+
prefix?: string;
|
|
80
|
+
title?: string;
|
|
81
|
+
description?: string;
|
|
82
|
+
/** Promo code, shown in a dashed chip (tap to copy when `onCopy` is set). */
|
|
83
|
+
code?: string;
|
|
84
|
+
brand?: string;
|
|
85
|
+
/** Logo before the brand (e.g. an <Image>). */
|
|
86
|
+
logo?: React.ReactNode;
|
|
87
|
+
/** Expiry line, e.g. "Valid till 31 Dec". */
|
|
88
|
+
expires?: string;
|
|
89
|
+
/** Live countdown to this moment; the coupon turns expired when it passes. */
|
|
90
|
+
expiresAt?: Date | number;
|
|
91
|
+
/** Shown on the back — the "T&C" link turns the coupon over. */
|
|
92
|
+
terms?: string[];
|
|
93
|
+
status?: CouponStatus;
|
|
94
|
+
/** Why it's locked, e.g. "Add ₹120 more to unlock". */
|
|
95
|
+
lockedHint?: string;
|
|
96
|
+
/** A preset or your own colours (default "sunset"). */
|
|
97
|
+
theme?: CouponThemeName | CouponTheme;
|
|
98
|
+
/** Override the theme's texture. */
|
|
99
|
+
pattern?: TicketPattern;
|
|
100
|
+
/** `horizontal` (value panel on the left) or `vertical` (value panel on top). */
|
|
101
|
+
layout?: 'horizontal' | 'vertical';
|
|
102
|
+
variant?: CouponVariant;
|
|
103
|
+
size?: CouponSize;
|
|
104
|
+
/** A light sweep across the value panel every few seconds. */
|
|
105
|
+
shine?: boolean;
|
|
106
|
+
/** 3D: tilts toward the finger with a moving reflection. */
|
|
107
|
+
tilt?: boolean;
|
|
108
|
+
/** With `tilt`: a rainbow foil sheen. */
|
|
109
|
+
holo?: boolean;
|
|
110
|
+
/** With `tilt`: sway slowly on its own. */
|
|
111
|
+
idle?: boolean;
|
|
112
|
+
/** Called with the code from the chip — wire it to expo-clipboard. */
|
|
113
|
+
onCopy?: (code: string) => void;
|
|
114
|
+
/** Shows the Apply button. */
|
|
115
|
+
onApply?: () => void;
|
|
116
|
+
/** Tap on the coupon itself (e.g. open details or select it). */
|
|
117
|
+
onPress?: () => void;
|
|
118
|
+
flipped?: boolean;
|
|
119
|
+
defaultFlipped?: boolean;
|
|
120
|
+
onFlip?: (flipped: boolean) => void;
|
|
121
|
+
labels?: CouponLabels;
|
|
122
|
+
style?: StyleProp<ViewStyle>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const VARIANTS: Record<CouponVariant, { r: number; notch: number; edge?: TicketEdge; edgeSize: number; ends?: boolean }> = {
|
|
126
|
+
ticket: { r: 16, notch: 10, edgeSize: 0 },
|
|
127
|
+
stamp: { r: 3, notch: 9, edge: 'perforated', edgeSize: 4 },
|
|
128
|
+
receipt: { r: 4, notch: 10, edge: 'zigzag', edgeSize: 6, ends: true },
|
|
129
|
+
scallop: { r: 0, notch: 10, edge: 'scalloped', edgeSize: 5 },
|
|
130
|
+
cutout: { r: 12, notch: 0, edgeSize: 0 },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const SIZES = {
|
|
134
|
+
sm: { panel: 92, value: 30, pad: 12, title: 14, desc: 12, chip: 12.5, gap: 6 },
|
|
135
|
+
md: { panel: 110, value: 38, pad: 14, title: 15.5, desc: 12.5, chip: 13.5, gap: 8 },
|
|
136
|
+
lg: { panel: 128, value: 46, pad: 16, title: 17, desc: 13.5, chip: 14.5, gap: 10 },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const ICON = {
|
|
140
|
+
copy: 'M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z',
|
|
141
|
+
check: 'M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z',
|
|
142
|
+
lock: 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z',
|
|
143
|
+
clock: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z',
|
|
144
|
+
scissors:
|
|
145
|
+
'M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3z',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function Icon({ d, size = 14, color }: { d: string; size?: number; color: string }) {
|
|
149
|
+
return (
|
|
150
|
+
<Svg width={size} height={size} viewBox="0 0 24 24">
|
|
151
|
+
<Path d={d} fill={color} />
|
|
152
|
+
</Svg>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** WCAG contrast between two #rgb / #rrggbb colours (1 … 21); unknown formats count as readable. */
|
|
157
|
+
function contrast(a: string, b: string) {
|
|
158
|
+
const lum = (hex: string) => {
|
|
159
|
+
const full = hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex;
|
|
160
|
+
const n = parseInt(full.slice(1, 7), 16);
|
|
161
|
+
if (!/^#[0-9a-f]{6}/i.test(full) || Number.isNaN(n)) return -1;
|
|
162
|
+
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
|
|
163
|
+
const c = v / 255;
|
|
164
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
165
|
+
});
|
|
166
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
167
|
+
};
|
|
168
|
+
const [x, y] = [lum(a), lum(b)];
|
|
169
|
+
if (x < 0 || y < 0) return 21;
|
|
170
|
+
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const DAY = 86_400_000;
|
|
174
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
175
|
+
|
|
176
|
+
/** "2d 4h" while there's more than a day, then "05:42:10". */
|
|
177
|
+
export function formatCountdown(ms: number) {
|
|
178
|
+
const s = Math.floor(ms / 1000);
|
|
179
|
+
const d = Math.floor(s / 86400);
|
|
180
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
181
|
+
return d > 0 ? `${d}d ${h}h` : `${pad2(h)}:${pad2(Math.floor((s % 3600) / 60))}:${pad2(s % 60)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Milliseconds left until `at` — ticks every second in the last day, every 30s before that. */
|
|
185
|
+
function useRemaining(at?: Date | number) {
|
|
186
|
+
const target = at === undefined ? undefined : new Date(at).getTime();
|
|
187
|
+
const [now, setNow] = useState(() => Date.now());
|
|
188
|
+
const left = target === undefined ? undefined : Math.max(0, target - now);
|
|
189
|
+
const live = left !== undefined && left > 0;
|
|
190
|
+
const fast = live && left < DAY;
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!live) return;
|
|
193
|
+
const id = setInterval(() => setNow(Date.now()), fast ? 1000 : 30_000);
|
|
194
|
+
return () => clearInterval(id);
|
|
195
|
+
}, [target, live, fast]);
|
|
196
|
+
return left;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The promo code in a dashed chip; a tap copies it and flips the icon to a check for a moment. */
|
|
200
|
+
function CodeChip({ code, color, ink, fontSize, onCopy, labels }: { code: string; color: string; ink: string; fontSize: number; onCopy?: (code: string) => void; labels: Required<CouponLabels> }) {
|
|
201
|
+
const [copied, setCopied] = useState(false);
|
|
202
|
+
const [box, setBox] = useState({ w: 0, h: 0 });
|
|
203
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
204
|
+
const pop = useRef(new Animated.Value(1)).current;
|
|
205
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
206
|
+
const copy = () => {
|
|
207
|
+
onCopy?.(code);
|
|
208
|
+
setCopied(true);
|
|
209
|
+
pop.setValue(0.4);
|
|
210
|
+
Animated.spring(pop, { toValue: 1, ...motion.bouncy, useNativeDriver: true }).start();
|
|
211
|
+
clearTimeout(timer.current);
|
|
212
|
+
timer.current = setTimeout(() => setCopied(false), 1600);
|
|
213
|
+
};
|
|
214
|
+
return (
|
|
215
|
+
<Pressable
|
|
216
|
+
onPress={onCopy ? copy : undefined}
|
|
217
|
+
disabled={!onCopy}
|
|
218
|
+
hitSlop={6}
|
|
219
|
+
accessibilityRole={onCopy ? 'button' : 'text'}
|
|
220
|
+
accessibilityLabel={onCopy ? `${copied ? labels.copied : labels.copy} ${code}` : code}
|
|
221
|
+
onLayout={(e) => setBox({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })}
|
|
222
|
+
style={({ pressed }) => [styles.chip, { backgroundColor: alpha(color, pressed ? 0.16 : 0.08) }]}
|
|
223
|
+
>
|
|
224
|
+
{box.w ? (
|
|
225
|
+
<Svg width="100%" height="100%" style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
226
|
+
<Rect x={0.75} y={0.75} width={box.w - 1.5} height={box.h - 1.5} rx={8} fill="none" stroke={alpha(color, 0.75)} strokeWidth={1.5} strokeDasharray={[5, 4]} />
|
|
227
|
+
</Svg>
|
|
228
|
+
) : null}
|
|
229
|
+
<Text numberOfLines={1} style={{ color: ink, fontSize, fontWeight: '800', letterSpacing: 1, flexShrink: 1 }}>
|
|
230
|
+
{code}
|
|
231
|
+
</Text>
|
|
232
|
+
{onCopy ? (
|
|
233
|
+
<Animated.View style={{ transform: [{ scale: pop }] }}>
|
|
234
|
+
{copied ? <Icon d={ICON.check} size={fontSize + 2} color="#16a34a" /> : <Icon d={ICON.copy} size={fontSize + 1} color={color} />}
|
|
235
|
+
</Animated.View>
|
|
236
|
+
) : null}
|
|
237
|
+
</Pressable>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A light band that sweeps across, every few seconds, through the given windows. */
|
|
242
|
+
function Shine({ windows, width, height }: { windows: TicketRect[]; width: number; height: number }) {
|
|
243
|
+
const t = useRef(new Animated.Value(0)).current;
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const loop = Animated.loop(
|
|
246
|
+
Animated.sequence([Animated.timing(t, { toValue: 1, duration: 1100, easing: Easing.inOut(Easing.quad), useNativeDriver: true }), Animated.delay(2400)])
|
|
247
|
+
);
|
|
248
|
+
loop.start();
|
|
249
|
+
return () => loop.stop();
|
|
250
|
+
}, [t]);
|
|
251
|
+
const band = Math.max(36, width * 0.35);
|
|
252
|
+
const tall = height * 2;
|
|
253
|
+
const P = 24;
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
{windows.map((r, i) => (
|
|
257
|
+
<View key={i} pointerEvents="none" style={[styles.window, { left: r.x, top: r.y, width: r.width, height: r.height }]}>
|
|
258
|
+
<Animated.View
|
|
259
|
+
style={{
|
|
260
|
+
position: 'absolute',
|
|
261
|
+
width: P,
|
|
262
|
+
height: P,
|
|
263
|
+
left: -r.x - P / 2,
|
|
264
|
+
top: -r.y + height / 2 - P / 2,
|
|
265
|
+
transform: [
|
|
266
|
+
{ translateX: t.interpolate({ inputRange: [0, 1], outputRange: [-band, width + band] }) },
|
|
267
|
+
{ rotate: '20deg' },
|
|
268
|
+
{ scaleX: band / P },
|
|
269
|
+
{ scaleY: tall / P },
|
|
270
|
+
],
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
<Svg width={P} height={P}>
|
|
274
|
+
<Defs>
|
|
275
|
+
<LinearGradient id="coupon-shine" x1="0" y1="0" x2="1" y2="0">
|
|
276
|
+
<Stop offset="0" stopColor="#ffffff" stopOpacity={0} />
|
|
277
|
+
<Stop offset="0.5" stopColor="#ffffff" stopOpacity={0.55} />
|
|
278
|
+
<Stop offset="1" stopColor="#ffffff" stopOpacity={0} />
|
|
279
|
+
</LinearGradient>
|
|
280
|
+
</Defs>
|
|
281
|
+
<Rect width={P} height={P} fill="url(#coupon-shine)" />
|
|
282
|
+
</Svg>
|
|
283
|
+
</Animated.View>
|
|
284
|
+
</View>
|
|
285
|
+
))}
|
|
286
|
+
</>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A coupon worth tapping: a vivid value panel (gradient, sunburst, confetti…) joined to a details
|
|
292
|
+
* panel by a perforated tear line with real cut-outs. Copyable code, Apply button, live countdown,
|
|
293
|
+
* applied / used / expired / locked states, a light sweep, and an optional 3D tilt with a holo sheen
|
|
294
|
+
* that flips over to the terms. Flat by default, so long lists stay light.
|
|
295
|
+
*/
|
|
296
|
+
export function Coupon({
|
|
297
|
+
value,
|
|
298
|
+
unit,
|
|
299
|
+
prefix,
|
|
300
|
+
title,
|
|
301
|
+
description,
|
|
302
|
+
code,
|
|
303
|
+
brand,
|
|
304
|
+
logo,
|
|
305
|
+
expires,
|
|
306
|
+
expiresAt,
|
|
307
|
+
terms,
|
|
308
|
+
status = 'available',
|
|
309
|
+
lockedHint,
|
|
310
|
+
theme: themeProp = 'sunset',
|
|
311
|
+
pattern: patternProp,
|
|
312
|
+
layout = 'horizontal',
|
|
313
|
+
variant = 'ticket',
|
|
314
|
+
size = 'md',
|
|
315
|
+
shine = false,
|
|
316
|
+
tilt = false,
|
|
317
|
+
holo = false,
|
|
318
|
+
idle = false,
|
|
319
|
+
onCopy,
|
|
320
|
+
onApply,
|
|
321
|
+
onPress,
|
|
322
|
+
flipped: flippedProp,
|
|
323
|
+
defaultFlipped = false,
|
|
324
|
+
onFlip,
|
|
325
|
+
labels: labelsProp,
|
|
326
|
+
style,
|
|
327
|
+
}: CouponProps) {
|
|
328
|
+
const { colors, dark } = useKit();
|
|
329
|
+
const theme = typeof themeProp === 'object' ? themeProp : couponThemes[themeProp];
|
|
330
|
+
const fills = typeof theme.fill === 'string' ? [theme.fill] : theme.fill;
|
|
331
|
+
const accent = theme.accent ?? fills[fills.length - 1];
|
|
332
|
+
const body = theme.body ?? (dark ? colors.popover : colors.card);
|
|
333
|
+
const bodyInk = theme.bodyInk ?? colors.foreground;
|
|
334
|
+
const bodySoft = alpha(bodyInk, 0.6);
|
|
335
|
+
// The accent as text on the details panel — or the panel's ink when they'd blend (yellow on white).
|
|
336
|
+
const textAccent = contrast(accent, typeof body === 'string' ? body : body[0]) >= 2.6 ? accent : bodyInk;
|
|
337
|
+
const s = SIZES[size];
|
|
338
|
+
const v = VARIANTS[variant];
|
|
339
|
+
const horizontal = layout === 'horizontal';
|
|
340
|
+
const labels: Required<CouponLabels> = {
|
|
341
|
+
copy: 'Copy',
|
|
342
|
+
copied: 'Copied!',
|
|
343
|
+
apply: 'Apply',
|
|
344
|
+
applied: 'Applied',
|
|
345
|
+
used: 'Used',
|
|
346
|
+
expired: 'Expired',
|
|
347
|
+
locked: 'Locked',
|
|
348
|
+
endsIn: 'Ends in',
|
|
349
|
+
terms: 'T&C',
|
|
350
|
+
termsTitle: 'Terms & conditions',
|
|
351
|
+
back: 'Tap to turn back',
|
|
352
|
+
...labelsProp,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const left = useRemaining(expiresAt);
|
|
356
|
+
const state: CouponStatus = status === 'available' && left === 0 ? 'expired' : status;
|
|
357
|
+
const spent = state === 'used' || state === 'expired';
|
|
358
|
+
const locked = state === 'locked';
|
|
359
|
+
|
|
360
|
+
const controlled = flippedProp !== undefined;
|
|
361
|
+
const [own, setOwn] = useState(defaultFlipped);
|
|
362
|
+
const flipped = controlled ? flippedProp : own;
|
|
363
|
+
// One flip drives the card and the tilt shadow, so the shadow never shows as a dark box mid-turn.
|
|
364
|
+
const flip = useFlipProgress(flipped);
|
|
365
|
+
const [backReady, setBackReady] = useState(flipped);
|
|
366
|
+
const canFlip = !!terms?.length;
|
|
367
|
+
const toggle = () => {
|
|
368
|
+
if (!canFlip) return;
|
|
369
|
+
setBackReady(true);
|
|
370
|
+
if (!controlled) setOwn(!flipped);
|
|
371
|
+
onFlip?.(!flipped);
|
|
372
|
+
};
|
|
373
|
+
// Once the back has been shown it stays mounted (it has to, to turn back); until then it costs nothing.
|
|
374
|
+
if (flipped && !backReady) setBackReady(true);
|
|
375
|
+
|
|
376
|
+
// Outline: two panels meet at the tear line; their cut-in corners make the notches.
|
|
377
|
+
const n = v.notch;
|
|
378
|
+
const endEdge = v.edge;
|
|
379
|
+
const sideEdge = v.ends ? undefined : v.edge;
|
|
380
|
+
const valueEdges: Partial<Record<TicketSide, TicketEdge>> = horizontal ? { left: endEdge, top: sideEdge, bottom: sideEdge } : { top: endEdge, left: sideEdge, right: sideEdge };
|
|
381
|
+
const detailEdges: Partial<Record<TicketSide, TicketEdge>> = horizontal ? { right: endEdge, top: sideEdge, bottom: sideEdge } : { bottom: endEdge, left: sideEdge, right: sideEdge };
|
|
382
|
+
const corners = (tl: number, tr: number, br: number, bl: number): TicketCorners => [tl, tr, br, bl];
|
|
383
|
+
const valueCorners = horizontal ? corners(v.r, -n, -n, v.r) : corners(v.r, v.r, -n, -n);
|
|
384
|
+
const detailCorners = horizontal ? corners(-n, v.r, v.r, -n) : corners(-n, -n, v.r, v.r);
|
|
385
|
+
const [valueBox, setValueBox] = useState({ width: 0, height: 0 });
|
|
386
|
+
const [detailBox, setDetailBox] = useState({ width: 0, height: 0 });
|
|
387
|
+
const W = horizontal ? valueBox.width + detailBox.width : detailBox.width;
|
|
388
|
+
const H = horizontal ? detailBox.height : valueBox.height + detailBox.height;
|
|
389
|
+
const split = horizontal ? valueBox.width : valueBox.height;
|
|
390
|
+
const outline: TicketGeometry = {
|
|
391
|
+
width: W,
|
|
392
|
+
height: H,
|
|
393
|
+
corners: v.r,
|
|
394
|
+
edgeSize: v.edgeSize,
|
|
395
|
+
edges: horizontal ? { left: endEdge, right: endEdge, top: sideEdge, bottom: sideEdge } : { top: endEdge, bottom: endEdge, left: sideEdge, right: sideEdge },
|
|
396
|
+
notches: n
|
|
397
|
+
? horizontal
|
|
398
|
+
? [{ side: 'top', at: split, radius: n }, { side: 'bottom', at: split, radius: n }]
|
|
399
|
+
: [{ side: 'left', at: split, radius: n }, { side: 'right', at: split, radius: n }]
|
|
400
|
+
: [],
|
|
401
|
+
};
|
|
402
|
+
const measured = W > 0 && H > 0 && split > 0;
|
|
403
|
+
const edgePad = v.edgeSize;
|
|
404
|
+
|
|
405
|
+
// Locked coupons go grey; the rest keep their colours.
|
|
406
|
+
const valueFill = locked ? ['#a3a8b3', '#6b7280'] : theme.fill;
|
|
407
|
+
const valueInk = locked ? '#ffffff' : theme.ink;
|
|
408
|
+
const valueSoft = alpha(valueInk, 0.82);
|
|
409
|
+
const pattern = patternProp ?? theme.pattern ?? 'none';
|
|
410
|
+
const vLen = value.length;
|
|
411
|
+
const valueSize = Math.round(s.value * (horizontal ? 1 : 1.3) * (vLen <= 3 ? 1 : vLen <= 4 ? 0.86 : vLen <= 5 ? 0.74 : 0.62));
|
|
412
|
+
const lift = valueInk === '#ffffff' ? styles.lift : null;
|
|
413
|
+
const inline = !horizontal && value.length + (unit?.length ?? 0) <= 9;
|
|
414
|
+
|
|
415
|
+
// The countdown chip: calm until the last hour, then red. On the colourful panel (vertical) it
|
|
416
|
+
// sits on a dark glass chip, and turns solid red when urgent, so it reads on any colour.
|
|
417
|
+
const urgent = left !== undefined && left < 3_600_000;
|
|
418
|
+
const timerBg = horizontal ? alpha(urgent ? '#ef4444' : bodyInk, urgent ? 0.14 : 0.08) : urgent ? '#ef4444' : 'rgba(0,0,0,0.22)';
|
|
419
|
+
const timerInk = horizontal ? (urgent ? '#ef4444' : bodySoft) : '#ffffff';
|
|
420
|
+
const countdown =
|
|
421
|
+
left !== undefined && left > 0 && !spent ? (
|
|
422
|
+
<View style={[styles.timer, { backgroundColor: timerBg }]}>
|
|
423
|
+
<Icon d={ICON.clock} size={11} color={timerInk} />
|
|
424
|
+
<Text style={{ color: timerInk, fontSize: 10.5, fontWeight: '800', fontVariant: ['tabular-nums'] }}>
|
|
425
|
+
{labels.endsIn} {formatCountdown(left)}
|
|
426
|
+
</Text>
|
|
427
|
+
</View>
|
|
428
|
+
) : null;
|
|
429
|
+
|
|
430
|
+
const brandRow =
|
|
431
|
+
brand || logo || countdown ? (
|
|
432
|
+
<View style={styles.row}>
|
|
433
|
+
<View style={[styles.row, { gap: 6, flexShrink: 1 }]}>
|
|
434
|
+
{logo}
|
|
435
|
+
{brand ? (
|
|
436
|
+
<Text numberOfLines={1} style={{ color: horizontal ? bodySoft : valueSoft, fontSize: 10.5, fontWeight: '800', letterSpacing: 1.2, flexShrink: 1 }}>
|
|
437
|
+
{brand.toUpperCase()}
|
|
438
|
+
</Text>
|
|
439
|
+
) : null}
|
|
440
|
+
</View>
|
|
441
|
+
{countdown}
|
|
442
|
+
</View>
|
|
443
|
+
) : null;
|
|
444
|
+
|
|
445
|
+
// Value panel ------------------------------------------------------------------------------------
|
|
446
|
+
const valueBlock = (
|
|
447
|
+
<View style={{ alignItems: 'center' }}>
|
|
448
|
+
{prefix ? <Text style={[{ color: valueSoft, fontSize: 10.5, fontWeight: '900', letterSpacing: 1.6 }, lift]}>{prefix.toUpperCase()}</Text> : null}
|
|
449
|
+
{locked ? <Icon d={ICON.lock} size={valueSize * 0.6} color={valueInk} /> : null}
|
|
450
|
+
{/* Vertical coupons set "50% OFF" on one line when it fits, and stack a long unit ("CASHBACK"). */}
|
|
451
|
+
<View style={{ flexDirection: inline ? 'row' : 'column', alignItems: inline ? 'flex-end' : 'center', gap: inline ? 6 : 0 }}>
|
|
452
|
+
<Text numberOfLines={1} adjustsFontSizeToFit style={[{ color: valueInk, fontSize: valueSize, lineHeight: valueSize * 1.08, fontWeight: '900', letterSpacing: -1.2 }, lift]}>
|
|
453
|
+
{value}
|
|
454
|
+
</Text>
|
|
455
|
+
{unit ? (
|
|
456
|
+
<Text style={[{ color: valueInk, fontSize: horizontal ? 12.5 : 16, fontWeight: '900', letterSpacing: 2.4, marginBottom: inline ? valueSize * 0.14 : 0 }, lift]}>
|
|
457
|
+
{unit.toUpperCase()}
|
|
458
|
+
</Text>
|
|
459
|
+
) : null}
|
|
460
|
+
</View>
|
|
461
|
+
</View>
|
|
462
|
+
);
|
|
463
|
+
const valuePanel = (
|
|
464
|
+
<TicketShape
|
|
465
|
+
corners={valueCorners}
|
|
466
|
+
edges={valueEdges}
|
|
467
|
+
edgeSize={v.edgeSize}
|
|
468
|
+
fill={valueFill}
|
|
469
|
+
pattern={locked ? 'none' : pattern}
|
|
470
|
+
seed={code ?? value}
|
|
471
|
+
onSize={setValueBox}
|
|
472
|
+
style={
|
|
473
|
+
horizontal
|
|
474
|
+
? { width: s.panel, justifyContent: 'center', paddingVertical: s.pad + edgePad, paddingLeft: 8 + edgePad, paddingRight: 8 }
|
|
475
|
+
: { paddingHorizontal: s.pad + edgePad, paddingTop: s.pad + edgePad, paddingBottom: s.pad + 4, gap: 10 }
|
|
476
|
+
}
|
|
477
|
+
>
|
|
478
|
+
{horizontal ? (
|
|
479
|
+
valueBlock
|
|
480
|
+
) : (
|
|
481
|
+
<>
|
|
482
|
+
{brandRow}
|
|
483
|
+
{valueBlock}
|
|
484
|
+
{title ? (
|
|
485
|
+
<Text numberOfLines={2} style={[{ color: valueInk, fontSize: s.title + 1, fontWeight: '800', textAlign: 'center', letterSpacing: -0.3 }, lift]}>
|
|
486
|
+
{title}
|
|
487
|
+
</Text>
|
|
488
|
+
) : null}
|
|
489
|
+
</>
|
|
490
|
+
)}
|
|
491
|
+
</TicketShape>
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// Details panel ----------------------------------------------------------------------------------
|
|
495
|
+
const applied = state === 'applied';
|
|
496
|
+
const action = onApply ? (
|
|
497
|
+
<Pressable
|
|
498
|
+
onPress={onApply}
|
|
499
|
+
disabled={state !== 'available'}
|
|
500
|
+
accessibilityRole="button"
|
|
501
|
+
accessibilityState={{ disabled: state !== 'available', selected: applied }}
|
|
502
|
+
hitSlop={6}
|
|
503
|
+
style={({ pressed }) => [
|
|
504
|
+
styles.apply,
|
|
505
|
+
{
|
|
506
|
+
marginLeft: 'auto',
|
|
507
|
+
backgroundColor: applied ? '#16a34a' : locked || spent ? alpha(bodyInk, 0.12) : accent,
|
|
508
|
+
transform: [{ scale: pressed ? 0.95 : 1 }],
|
|
509
|
+
paddingHorizontal: size === 'sm' ? 11 : size === 'md' ? 13 : 16,
|
|
510
|
+
},
|
|
511
|
+
]}
|
|
512
|
+
>
|
|
513
|
+
{applied || locked ? <Icon d={applied ? ICON.check : ICON.lock} size={13} color={applied ? '#ffffff' : bodySoft} /> : null}
|
|
514
|
+
<Text numberOfLines={1} style={{ color: applied ? '#ffffff' : locked || spent ? bodySoft : readableOn(accent), fontSize: s.chip - 0.5, fontWeight: '800' }}>
|
|
515
|
+
{applied ? labels.applied : locked ? labels.locked : labels.apply}
|
|
516
|
+
</Text>
|
|
517
|
+
</Pressable>
|
|
518
|
+
) : null;
|
|
519
|
+
|
|
520
|
+
const footer =
|
|
521
|
+
expires || canFlip ? (
|
|
522
|
+
<View style={[styles.row, { justifyContent: 'flex-start', gap: 6 }]}>
|
|
523
|
+
{expires ? <Text style={{ color: bodySoft, fontSize: 11, fontWeight: '600' }}>{expires}</Text> : null}
|
|
524
|
+
{expires && canFlip ? <Text style={{ color: bodySoft, fontSize: 11 }}>·</Text> : null}
|
|
525
|
+
{canFlip ? (
|
|
526
|
+
<Pressable onPress={toggle} hitSlop={10} accessibilityRole="button" accessibilityLabel={labels.termsTitle}>
|
|
527
|
+
<Text style={{ color: textAccent, fontSize: 11, fontWeight: '800', textDecorationLine: 'underline' }}>{labels.terms}</Text>
|
|
528
|
+
</Pressable>
|
|
529
|
+
) : null}
|
|
530
|
+
</View>
|
|
531
|
+
) : null;
|
|
532
|
+
|
|
533
|
+
const detailPanel = (
|
|
534
|
+
<TicketShape
|
|
535
|
+
corners={detailCorners}
|
|
536
|
+
edges={detailEdges}
|
|
537
|
+
edgeSize={v.edgeSize}
|
|
538
|
+
fill={body}
|
|
539
|
+
tear={n || variant === 'cutout' ? (horizontal ? 'left' : 'top') : undefined}
|
|
540
|
+
tearColor={alpha(bodyInk, variant === 'cutout' ? 0.45 : 0.22)}
|
|
541
|
+
onSize={setDetailBox}
|
|
542
|
+
style={[
|
|
543
|
+
horizontal ? { flex: 1 } : null,
|
|
544
|
+
{
|
|
545
|
+
paddingTop: (horizontal ? s.pad + edgePad : s.pad + 2) + (variant === 'cutout' ? 2 : 0),
|
|
546
|
+
paddingBottom: s.pad + edgePad,
|
|
547
|
+
paddingLeft: horizontal ? s.pad + 2 : s.pad + edgePad,
|
|
548
|
+
paddingRight: s.pad + edgePad,
|
|
549
|
+
gap: s.gap,
|
|
550
|
+
},
|
|
551
|
+
]}
|
|
552
|
+
>
|
|
553
|
+
{horizontal ? brandRow : null}
|
|
554
|
+
{horizontal && title ? (
|
|
555
|
+
<Text numberOfLines={2} style={{ color: bodyInk, fontSize: s.title, fontWeight: '800', letterSpacing: -0.3 }}>
|
|
556
|
+
{title}
|
|
557
|
+
</Text>
|
|
558
|
+
) : null}
|
|
559
|
+
{locked && lockedHint ? (
|
|
560
|
+
<Text numberOfLines={2} style={{ color: textAccent, fontSize: s.desc, fontWeight: '700' }}>
|
|
561
|
+
{lockedHint}
|
|
562
|
+
</Text>
|
|
563
|
+
) : description ? (
|
|
564
|
+
<Text numberOfLines={size === 'sm' ? 1 : 2} style={{ color: bodySoft, fontSize: s.desc, lineHeight: s.desc * 1.35 }}>
|
|
565
|
+
{description}
|
|
566
|
+
</Text>
|
|
567
|
+
) : null}
|
|
568
|
+
{code || action ? (
|
|
569
|
+
// The code never gets cut short: when it and the button don't fit side by side, the button
|
|
570
|
+
// wraps under it (and stays right-aligned).
|
|
571
|
+
<View style={[styles.row, { gap: 8, marginTop: 2, flexWrap: 'wrap' }]}>
|
|
572
|
+
{code ? <CodeChip code={code} color={textAccent} ink={bodyInk} fontSize={s.chip} onCopy={spent || locked ? undefined : onCopy} labels={labels} /> : <View />}
|
|
573
|
+
{action}
|
|
574
|
+
</View>
|
|
575
|
+
) : null}
|
|
576
|
+
{footer}
|
|
577
|
+
</TicketShape>
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
const { width: _w, height: _h, ...shape } = outline;
|
|
581
|
+
const glareClip = measured ? ticketSafeRects(outline) : undefined;
|
|
582
|
+
const valueWindows = shine && measured && !spent && !locked
|
|
583
|
+
? ticketSafeRects({ width: valueBox.width, height: valueBox.height, corners: valueCorners, edges: valueEdges, edgeSize: v.edgeSize })
|
|
584
|
+
: null;
|
|
585
|
+
|
|
586
|
+
const front = (
|
|
587
|
+
<View>
|
|
588
|
+
<View style={[{ flexDirection: horizontal ? 'row' : 'column' }, spent ? { opacity: 0.5 } : null]}>
|
|
589
|
+
{valuePanel}
|
|
590
|
+
{detailPanel}
|
|
591
|
+
</View>
|
|
592
|
+
{valueWindows ? <Shine windows={valueWindows} width={valueBox.width} height={valueBox.height} /> : null}
|
|
593
|
+
{variant === 'cutout' && measured ? (
|
|
594
|
+
<>
|
|
595
|
+
{/* "Cut here": a dashed frame just inside the edge, and scissors on the tear line. */}
|
|
596
|
+
<Svg pointerEvents="none" width="100%" height="100%" style={StyleSheet.absoluteFill}>
|
|
597
|
+
<Rect x={5} y={5} width={W - 10} height={H - 10} rx={Math.max(0, v.r - 4)} fill="none" stroke={alpha(bodyInk, 0.4)} strokeWidth={1.5} strokeDasharray={[6, 5]} />
|
|
598
|
+
</Svg>
|
|
599
|
+
<View pointerEvents="none" style={[styles.scissors, { backgroundColor: typeof body === 'string' ? body : body[0] }, horizontal ? { left: split - 11, top: -2 } : { top: split - 11, left: -2, transform: [{ rotate: '90deg' }] }]}>
|
|
600
|
+
<Icon d={ICON.scissors} size={16} color={alpha(bodyInk, 0.75)} />
|
|
601
|
+
</View>
|
|
602
|
+
</>
|
|
603
|
+
) : null}
|
|
604
|
+
{measured ? (
|
|
605
|
+
// A hairline around the real outline (notches, perforations and all), so a white coupon still
|
|
606
|
+
// reads on a white screen; it turns into a green ring once the coupon is applied.
|
|
607
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
608
|
+
<TicketShape
|
|
609
|
+
{...shape}
|
|
610
|
+
fill="transparent"
|
|
611
|
+
stroke={applied ? '#16a34a' : alpha(bodyInk, dark ? 0.16 : 0.11)}
|
|
612
|
+
strokeWidth={applied ? 3 : 2}
|
|
613
|
+
style={StyleSheet.absoluteFill}
|
|
614
|
+
/>
|
|
615
|
+
</View>
|
|
616
|
+
) : null}
|
|
617
|
+
{spent ? (
|
|
618
|
+
<View pointerEvents="none" style={[StyleSheet.absoluteFill, styles.center, horizontal ? { left: split } : { top: split }]}>
|
|
619
|
+
<TicketStamp text={state === 'used' ? labels.used : labels.expired} color={state === 'used' ? alpha(bodyInk, 0.75) : '#e5484d'} size={size === 'sm' ? 14 : 17} />
|
|
620
|
+
</View>
|
|
621
|
+
) : null}
|
|
622
|
+
</View>
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
const back =
|
|
626
|
+
canFlip && backReady ? (
|
|
627
|
+
<TicketShape {...shape} fill={body} style={StyleSheet.absoluteFill}>
|
|
628
|
+
<Pressable onPress={toggle} accessibilityRole="button" accessibilityHint={labels.back} style={[StyleSheet.absoluteFill, { padding: s.pad + edgePad, gap: 6, flexDirection: horizontal ? 'row' : 'column' }]}>
|
|
629
|
+
<View style={{ flex: 1, gap: 4 }}>
|
|
630
|
+
<Text style={{ color: bodyInk, fontSize: 12, fontWeight: '800', letterSpacing: 0.3 }}>{labels.termsTitle}</Text>
|
|
631
|
+
{terms!.slice(0, size === 'lg' ? 5 : 4).map((t, i) => (
|
|
632
|
+
<View key={i} style={{ flexDirection: 'row', gap: 6 }}>
|
|
633
|
+
<Text style={{ color: textAccent, fontSize: 11, fontWeight: '900' }}>•</Text>
|
|
634
|
+
<Text numberOfLines={2} style={{ color: bodySoft, fontSize: 11, lineHeight: 14.5, flex: 1 }}>
|
|
635
|
+
{t}
|
|
636
|
+
</Text>
|
|
637
|
+
</View>
|
|
638
|
+
))}
|
|
639
|
+
<Text style={{ color: alpha(bodyInk, 0.4), fontSize: 10, fontWeight: '700', marginTop: 'auto' }}>{labels.back}</Text>
|
|
640
|
+
</View>
|
|
641
|
+
{code ? (
|
|
642
|
+
<View style={{ alignItems: 'center', justifyContent: 'center', gap: 4 }}>
|
|
643
|
+
<View style={styles.plate}>
|
|
644
|
+
<BarcodeLike code={code} width={horizontal ? 74 : 200} height={horizontal ? 54 : 46} />
|
|
645
|
+
</View>
|
|
646
|
+
<Text style={{ color: bodyInk, fontSize: 10, fontWeight: '800', letterSpacing: 1.2 }}>{code}</Text>
|
|
647
|
+
</View>
|
|
648
|
+
) : null}
|
|
649
|
+
</Pressable>
|
|
650
|
+
</TicketShape>
|
|
651
|
+
) : (
|
|
652
|
+
<View />
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
const card = canFlip ? <FlipCard front={front} back={back} flipped={flipped} progress={flip.progress} trigger="none" perspective={1400} radius={v.r} shadow={false} /> : front;
|
|
656
|
+
|
|
657
|
+
if (tilt) {
|
|
658
|
+
return (
|
|
659
|
+
<TiltCard shadowScaleX={flip.shadowScaleX} onPress={flipped ? toggle : onPress} glare={holo ? 'holo' : 'soft'} glareClip={glareClip} idle={idle} maxTilt={10} radius={v.r} shadow={measured && !spent} style={style}>
|
|
660
|
+
{card}
|
|
661
|
+
</TiltCard>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
const inset = Math.max(n, v.edgeSize) + 8;
|
|
665
|
+
return (
|
|
666
|
+
<Pressable onPress={onPress} disabled={!onPress} accessibilityRole={onPress ? 'button' : undefined} style={style}>
|
|
667
|
+
{({ pressed }) => (
|
|
668
|
+
<View style={{ transform: [{ scale: pressed ? 0.985 : 1 }] }}>
|
|
669
|
+
{/* The plate waits for the painted outline (no black flash on mount), and a dimmed (used /
|
|
670
|
+
expired) coupon is see-through, so it gets none. */}
|
|
671
|
+
{spent || !measured ? null : <View pointerEvents="none" style={[styles.shadow, { top: inset, bottom: inset - 4, left: inset, right: inset, borderRadius: v.r, shadowOpacity: dark ? 0.5 : 0.2 }]} />}
|
|
672
|
+
{/* Elevation above the shadow plate keeps the coupon on top on Android (no background → no shadow of its own). */}
|
|
673
|
+
<View style={{ elevation: 7 }}>{card}</View>
|
|
674
|
+
</View>
|
|
675
|
+
)}
|
|
676
|
+
</Pressable>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const styles = StyleSheet.create({
|
|
681
|
+
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
|
682
|
+
center: { alignItems: 'center', justifyContent: 'center' },
|
|
683
|
+
lift: { textShadowColor: 'rgba(0,0,0,0.18)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 6 },
|
|
684
|
+
timer: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingHorizontal: 7, paddingVertical: 3, borderRadius: 999 },
|
|
685
|
+
chip: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 10, paddingVertical: 8, borderRadius: 8, maxWidth: '100%' },
|
|
686
|
+
apply: { flexDirection: 'row', alignItems: 'center', gap: 5, paddingVertical: 9, borderRadius: 999 },
|
|
687
|
+
window: { position: 'absolute', overflow: 'hidden' },
|
|
688
|
+
scissors: { position: 'absolute', width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center' },
|
|
689
|
+
plate: { padding: 5, borderRadius: 6, backgroundColor: '#ffffff' },
|
|
690
|
+
shadow: {
|
|
691
|
+
position: 'absolute',
|
|
692
|
+
backgroundColor: '#000',
|
|
693
|
+
shadowColor: '#000',
|
|
694
|
+
shadowRadius: 14,
|
|
695
|
+
shadowOffset: { width: 0, height: 8 },
|
|
696
|
+
elevation: 6,
|
|
697
|
+
},
|
|
698
|
+
});
|